├── .github └── workflows │ └── ruby.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── examples ├── ObjectSpaceBrowser.rb ├── ajax.rb ├── apotomo-webhunter │ ├── main.rb │ └── public │ │ ├── images │ │ ├── bear_trap_charged.png │ │ ├── bear_trap_snapped.png │ │ ├── cheese.png │ │ ├── dark_forest.jpg │ │ └── mouse.png │ │ ├── javascripts │ │ ├── jquery-1.3.2.min.js │ │ └── wee-jquery.js │ │ └── stylesheets │ │ └── forest.css ├── arc_challenge.rb ├── arc_challenge2.rb ├── cheese_task.rb ├── continuations.rb ├── demo.rb ├── demo │ ├── calculator.rb │ ├── calendar.rb │ ├── counter.rb │ ├── editable_counter.rb │ ├── example.rb │ ├── file_upload.rb │ ├── messagebox.rb │ ├── radio.rb │ └── window.rb ├── hw.rb ├── i18n │ ├── app.rb │ └── locale │ │ ├── de │ │ └── app.po │ │ └── en │ │ └── app.po └── pager.rb ├── lib ├── wee.rb └── wee │ ├── application.rb │ ├── callback.rb │ ├── component.rb │ ├── decoration.rb │ ├── dialog.rb │ ├── external_resource.rb │ ├── hello_world.rb │ ├── html_brushes.rb │ ├── html_canvas.rb │ ├── html_document.rb │ ├── html_writer.rb │ ├── id_generator.rb │ ├── jquery.rb │ ├── jquery │ ├── jquery-1.3.2.min.js │ └── wee-jquery.js │ ├── locale.rb │ ├── lru_cache.rb │ ├── presenter.rb │ ├── renderer.rb │ ├── request.rb │ ├── response.rb │ ├── rightjs.rb │ ├── rightjs │ ├── rightjs-1.5.2.min.js │ └── wee-rightjs.js │ ├── root_component.rb │ ├── run.rb │ ├── session.rb │ ├── state.rb │ ├── task.rb │ └── version.rb ├── spec └── component_spec.rb ├── test ├── bm_render.rb ├── stress │ ├── plotter.rb │ ├── stress_client.rb │ ├── stress_local.rb │ └── stress_server.rb ├── test_component.rb ├── test_html_canvas.rb ├── test_html_writer.rb ├── test_lru_cache.rb └── test_request.rb └── wee.gemspec /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Ruby 24 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 25 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 26 | # uses: ruby/setup-ruby@v1 27 | uses: ruby/setup-ruby@ec106b438a1ff6ff109590de34ddc62c540232e0 28 | with: 29 | ruby-version: 2.6 30 | - name: Install dependencies 31 | run: bundle install 32 | - name: Run tests 33 | run: bundle exec rake test 34 | - name: Run spec tests 35 | run: bundle exec rspec 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [x.y.z] - yyyy-mm-dd 8 | ### Changed 9 | 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in wee.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Michael Neumann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wee Web Framework 2 | 3 | ## Copyright and License 4 | 5 | Copyright (c) 2004-2020 by Michael Neumann (mneumann@ntecs.de). 6 | 7 | Released under the terms of the MIT license. 8 | 9 | ## Introduction 10 | 11 | Wee is a light-weight, very high-level and modern web-framework that makes 12 | *W*eb *e*ngineering *e*asy. It mainly inherits many ideas and features from 13 | [Seaside][seaside], but was written from scratch without ever looking at the 14 | Seaside (or any other) sources. All code was developed from ideas and lots of 15 | discussions with Avi Bryant. 16 | 17 | 18 | ## Features 19 | 20 | ### Reusable components 21 | 22 | Wee has _real_ components, which are like widgets in a GUI. Once written, you 23 | can use them everywhere. They are completely independent and do not interfere 24 | with other components. Components encapsulate state, a view and actions. Of 25 | course you can use an external model or use templates for rendering. 26 | 27 | ### Backtracking 28 | 29 | See the _What is backtracking?_ section below. In short, backtracking lets the 30 | browser's back and forward-button play well together with your application. 31 | 32 | ### Clean and concise 33 | 34 | Wee is well thought out, is written in *and* supports clean and concise code. 35 | Furthermore I think most parts are now very well documented. 36 | 37 | ### Templating-independent 38 | 39 | Wee does not depend on a special templating-engine. You can use a different 40 | templating engine for each component if you want. 41 | 42 | ### Powerful programmatic HTML generation 43 | 44 | Wee ships with an easy to use and very powerful programmatic HTML-generation 45 | library. For example you can create a select list easily with this piece of 46 | code: 47 | 48 | ```ruby 49 | # select an object from these items 50 | items = [1, 2, 3, 4] 51 | 52 | # the labels shown to the user 53 | labels = items.map {|i| i.to_s} 54 | 55 | # render it 56 | r.select_list(items).labels(labels).callback {|choosen| p choosen} 57 | 58 | # render a multi-select list, with objects 2 and 4 selected 59 | r.select_list(items).multi.labels(labels).selected([2,4]) 60 | ``` 61 | 62 | The callback is called with the selected objects from the _items_ array. Items 63 | can be any object, even whole components: 64 | 65 | ```ruby 66 | labels = ["msg1", "msg2"] 67 | items = labels.collect {|m| MessageBox.new(m)} 68 | r.select_list(items).labels(labels).callback {|choosen| call choosen.first} 69 | ``` 70 | 71 | ## Observations and Limitations 72 | 73 | * Components are thread-safe by nature as a fresh components-tree is created 74 | for each session and requests inside a session are serialized. 75 | 76 | ## What is backtracking? 77 | 78 | If you want, you can make the back-button of your browser work correctly 79 | together with your web-application. Imagine you have a simple counter 80 | application, which shows the current count and two links _inc_ and _dec_ with 81 | which you can increase or decrease the current count. Starting with an inital 82 | count of 0 you increase the counter up to 8, then click three times the back 83 | button of your browser (now displays 5). Finally you decrease by one and your 84 | counter shows what you'd have expected: 4. In contrast, traditional web 85 | applications would have shown 7, because the back button usually does not 86 | trigger a HTTP request and as such the server-side state still has a value of 8 87 | for the counter when the request to decrease comes in. 88 | 89 | The solution to this problem is to take snapshots of the components state after 90 | an action is performed and restoring the state before peforming actions. Each 91 | action generates a new state, which is indicated by a so-called _page-id_ 92 | within the URL. 93 | 94 | ## Decorations 95 | 96 | Decorations are used to modify the look and behaviour of a component without 97 | modifying the components tree itself. A component can have more than one 98 | decoration. Decorations are implemented as a linked list 99 | (`Wee::Decoration#next` points to the next decoration), starting at 100 | `Wee::Component#decoration`, which either points to the next decoration in the 101 | chain, or to itself. 102 | 103 | ## The request/response cycle 104 | 105 | The request/response cycle in Wee is actually split into two separate phases. 106 | 107 | ### Render Phase 108 | 109 | The rendering phase is assumed to be side-effect free! So, you as a programmer 110 | should take care to meet this assumption. Rendering is performed by method 111 | `Wee::Component#render!`. 112 | 113 | ### Action Phase (Invoking Callbacks) 114 | 115 | Possible sources for callbacks are links (anchors) and all kinds of 116 | form-elements like submit buttons, input-fields etc. There are two different 117 | kinds of callbacks: 118 | 119 | * Input callbacks (input-fields) 120 | 121 | * Action callbacks (anchor, submit-button) 122 | 123 | The distinction between input and action callbacks is important, as action 124 | callbacks might depend on values of input-fields being assigned to instance 125 | variables of the controlling component. Hence, Wee first invokes all input 126 | callbacks before any action callback is triggered. Callback processing is 127 | performed by method `Wee::Component#process_callbacks`. 128 | 129 | The result of the action phase is an updated components state. As such, a 130 | snapshot is taken of the new state and stored under a new page-id. Then, a 131 | redirect requests is sent back to the client, including this new page-id. The 132 | client automatically follows this redirect and triggers a render phase of the 133 | new page. 134 | 135 | [seaside]: http://seaside.st/ 136 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task :default => :spec 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << "lib" 8 | t.test_files = FileList['test/test_*.rb'] 9 | t.verbose = true 10 | end 11 | -------------------------------------------------------------------------------- /examples/ObjectSpaceBrowser.rb: -------------------------------------------------------------------------------- 1 | require 'wee' 2 | require 'enumerator' 3 | require_relative 'demo/messagebox' 4 | 5 | module ObjectSpaceBrowser 6 | 7 | class Klasses < Wee::Component 8 | 9 | def klasses 10 | ObjectSpace.to_enum(:each_object, Class).sort_by{|k| k.name} 11 | end 12 | 13 | def choose(klass) 14 | call Klass.new(klass) 15 | end 16 | 17 | def render(r) 18 | r.h1 "Classes" 19 | 20 | r.ul { 21 | klasses.each do |klass| 22 | r.li { r.anchor.callback_method(:choose, klass).with(klass.name) } 23 | end 24 | } 25 | end 26 | end 27 | 28 | class Klass < Wee::Component 29 | 30 | def initialize(klass) 31 | super() 32 | @klass = klass 33 | set_instances 34 | end 35 | 36 | def choose(instance) 37 | call Instance.new(instance) 38 | end 39 | 40 | ## 41 | # Fetches all instances of the klass sorted by object_id 42 | 43 | def set_instances 44 | @instances = 45 | case @klass 46 | when Symbol 47 | Symbol.all_symbols.sort_by do |s| s.to_s end 48 | else 49 | ObjectSpace.to_enum(:each_object, @klass).sort_by{|i| i.object_id} 50 | end 51 | end 52 | 53 | def render(r) 54 | instances = @instances 55 | r.h1 "Class #{@klass.name}" 56 | r.h2 "#{@instances.length} Instances" 57 | 58 | return if @instances.length.zero? 59 | 60 | r.ul { 61 | @instances.each do |instance| 62 | r.li { r.anchor.callback_method(:choose, instance).with("0x%x" % instance.object_id) } 63 | end 64 | } 65 | end 66 | 67 | end 68 | 69 | class Instance < Wee::Component 70 | 71 | def initialize(instance) 72 | super() 73 | @instance = instance 74 | end 75 | 76 | def choose(instance) 77 | call Instance.new(instance) 78 | end 79 | 80 | def back 81 | answer 82 | end 83 | 84 | def render(r) 85 | r.anchor.callback_method(:back).with("back") 86 | 87 | r.break 88 | r.h1 "Instance 0x%x of #{@instance.class.name}" % @instance.object_id 89 | 90 | case @instance 91 | when Array 92 | r.bold("array elements: ") 93 | r.break 94 | r.ul do 95 | @instance.each do |obj| 96 | r.li { render_obj(r, obj) } 97 | end 98 | end 99 | when Hash 100 | r.bold("hash elements: ") 101 | r.break 102 | r.table.border(1).with do 103 | r.table_row do 104 | r.table_data do r.bold("Key") end 105 | r.table_data do r.bold("Value") end 106 | end 107 | 108 | @instance.each_pair do |k, v| 109 | r.table_row do 110 | r.table_data { render_obj(r, k) } 111 | r.table_data { render_obj(r, v) } 112 | end 113 | end 114 | end 115 | 116 | when String, Float, Fixnum, Bignum, Numeric, Integer, Symbol 117 | r.encode_text(@instance.inspect) 118 | end 119 | 120 | return if @instance.instance_variables.empty? 121 | r.break 122 | 123 | render_instance_variables(r) 124 | end 125 | 126 | def render_instance_variables(r) 127 | r.table.border(1).with do 128 | r.table_row do 129 | r.table_data do r.bold("Instance Variable") end 130 | r.table_data do r.bold("Object") end 131 | end 132 | @instance.instance_variables.each do |var| render_ivar_row(r, var) end 133 | end 134 | end 135 | 136 | def render_ivar_row(r, var) 137 | r.table_row do 138 | r.table_data(var) 139 | r.table_data do 140 | v = @instance.instance_variable_get(var) 141 | render_obj(r, v) 142 | end 143 | end 144 | end 145 | 146 | def render_obj(r, obj) 147 | r.anchor.callback_method(:choose, obj).with do 148 | r.bold(obj.class.name) 149 | r.space 150 | r.text("(#{ obj.object_id })") 151 | r.space 152 | r.space 153 | 154 | case obj 155 | when String, Float, Integer, Symbol 156 | r.encode_text(obj.inspect) 157 | else 158 | r.encode_text(obj.inspect[0, 40] + "...") 159 | end 160 | end 161 | end 162 | 163 | end 164 | 165 | end # module ObjectSpaceBrowser 166 | 167 | if $0 == __FILE__ then 168 | 169 | OBJ = { 170 | "hello" => { [1,2,3] => [5,6,7], "test" => :super }, 171 | "other" => %w(a b c d e f) 172 | } 173 | 174 | class Main < Wee::Component 175 | def initialize 176 | super 177 | add_decoration(Wee::PageDecoration.new("Hello World")) 178 | @instance = ObjectSpaceBrowser::Instance.new(OBJ) 179 | end 180 | 181 | def children() [@instance] end 182 | 183 | def render(r) 184 | r.render(@instance) 185 | end 186 | end 187 | 188 | Wee.run(Main) 189 | end 190 | -------------------------------------------------------------------------------- /examples/ajax.rb: -------------------------------------------------------------------------------- 1 | require 'wee' 2 | require 'rack' 3 | 4 | class AjaxCounter < Wee::Component 5 | 6 | #require 'wee/jquery' 7 | #def self.depends; [Wee::JQuery] end 8 | 9 | require 'wee/rightjs' 10 | def self.depends; [Wee::RightJS] end 11 | 12 | def initialize 13 | @counter = 0 14 | end 15 | 16 | def state(s) 17 | super 18 | s.add_ivar(self, :@counter, @counter) 19 | end 20 | 21 | =begin 22 | def style 23 | "div.wee-AjaxCounter a { border: 1px solid blue; padding: 5px; background-color: #ABABAB; };" 24 | end 25 | 26 | def render(r) 27 | r.render_style(self) 28 | r.div.css_class('wee-AjaxCounter').oid.with { 29 | r.anchor.update_component_on(:click) { @counter += 1 }.with(@counter.to_s) 30 | } 31 | end 32 | =end 33 | 34 | def render(r) 35 | r.anchor.oid.update_component_on(:click) { @counter += 1 }.with(@counter.to_s) 36 | end 37 | 38 | end 39 | 40 | class HelloWorld < Wee::RootComponent 41 | 42 | def self.depends; [AjaxCounter.depends] end 43 | 44 | def title 45 | 'Wee + Ajax' 46 | end 47 | 48 | def initialize 49 | @counters = (1..10).map { AjaxCounter.new } 50 | end 51 | 52 | def children() @counters end 53 | 54 | def render(r) 55 | render_hello(r) 56 | r.div.callback_on(:click) { p "refresh" }.with("Refresh") 57 | @counters.each {|c| r.render(c); r.break} 58 | end 59 | 60 | def render_hello(r) 61 | @hello ||= "Hello" 62 | r.div.id("hello").update_on(:click) {|r| 63 | @hello.reverse! 64 | render_hello(r) 65 | }.with(@hello) 66 | end 67 | end 68 | 69 | if __FILE__ == $0 70 | Wee.run HelloWorld, :mount_path => '/ajax', :print_message => true 71 | end 72 | -------------------------------------------------------------------------------- /examples/apotomo-webhunter/main.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift '../../lib' 2 | require 'rubygems' 3 | require 'wee' 4 | 5 | class BearTrap < Wee::Component 6 | attr_accessor :mouse 7 | 8 | def initialize(is_charged=true) 9 | @charged = is_charged 10 | add_decoration Wee::OidDecoration.new 11 | end 12 | 13 | def render(r) 14 | img = @charged ? 'charged' : 'snapped' 15 | brush = r.div.id('bear_trap').style("background: transparent url('/images/bear_trap_#{img}.png');") 16 | if @charged 17 | if @over 18 | brush.update_on(:mouseout) {|r| 19 | @over = false 20 | r.render(self) 21 | } 22 | else 23 | brush.update_on(:mouseover) {|r| 24 | @over = true 25 | @mouse.update(r) 26 | if @mouse.cheese_count >= 3 27 | @charged = false 28 | end 29 | r.render(self) 30 | r.javascript("alert('gotcha')") unless @charged 31 | } 32 | end 33 | end 34 | brush.with { r.image.src('/images/cheese.png').id('cheese') } 35 | end 36 | end 37 | 38 | class Mouse < Wee::Component 39 | attr_reader :cheese_count 40 | 41 | def initialize(cheese_count=0) 42 | @cheese_count = cheese_count 43 | end 44 | 45 | def render(r) 46 | r.image.src("/images/mouse.png").id("mouse").width(90 * (@cheese_count+1)) 47 | end 48 | 49 | def update(r) 50 | @cheese_count += 1 51 | r.render(self) 52 | end 53 | end 54 | 55 | class Main < Wee::Component 56 | def initialize 57 | super 58 | add_decoration Wee::PageDecoration.new('A dark forest...', %w(/stylesheets/forest.css), 59 | %w(/javascripts/jquery-1.3.2.min.js /javascripts/wee-jquery.js)) 60 | @trap = BearTrap.new(true) 61 | @mouse = Mouse.new 62 | @trap.mouse = @mouse 63 | end 64 | 65 | def children() [@trap, @mouse] end 66 | 67 | def render(r) 68 | r.div.id('forest').with { 69 | r.render @trap 70 | r.render @mouse 71 | } 72 | end 73 | end 74 | 75 | Wee.run(Main, :public_path => File.join(File.dirname(__FILE__), 'public')) if __FILE__ == $0 76 | -------------------------------------------------------------------------------- /examples/apotomo-webhunter/public/images/bear_trap_charged.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mneumann/wee/76fa838dc41db272d23b278329e6016283cdff5b/examples/apotomo-webhunter/public/images/bear_trap_charged.png -------------------------------------------------------------------------------- /examples/apotomo-webhunter/public/images/bear_trap_snapped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mneumann/wee/76fa838dc41db272d23b278329e6016283cdff5b/examples/apotomo-webhunter/public/images/bear_trap_snapped.png -------------------------------------------------------------------------------- /examples/apotomo-webhunter/public/images/cheese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mneumann/wee/76fa838dc41db272d23b278329e6016283cdff5b/examples/apotomo-webhunter/public/images/cheese.png -------------------------------------------------------------------------------- /examples/apotomo-webhunter/public/images/dark_forest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mneumann/wee/76fa838dc41db272d23b278329e6016283cdff5b/examples/apotomo-webhunter/public/images/dark_forest.jpg -------------------------------------------------------------------------------- /examples/apotomo-webhunter/public/images/mouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mneumann/wee/76fa838dc41db272d23b278329e6016283cdff5b/examples/apotomo-webhunter/public/images/mouse.png -------------------------------------------------------------------------------- /examples/apotomo-webhunter/public/javascripts/wee-jquery.js: -------------------------------------------------------------------------------- 1 | var wee = {}; 2 | 3 | wee._update_elements = function(_,e) { 4 | var src = jQuery(e); 5 | var id = src.attr('id'); 6 | if (id) 7 | jQuery('#'+id).replaceWith(src); 8 | else 9 | jQuery('html > body').append(src); 10 | }; 11 | 12 | wee._update_callback = function(data) { 13 | jQuery(data).each(wee._update_elements); 14 | }; 15 | 16 | wee.update = function(url) { 17 | jQuery.get(url, {}, wee._update_callback, 'html'); 18 | return false; 19 | }; 20 | -------------------------------------------------------------------------------- /examples/apotomo-webhunter/public/stylesheets/forest.css: -------------------------------------------------------------------------------- 1 | /* 2 | all images were stolen and may be licensed. 3 | thanks to felix, the best programmer i've ever met, for helping me out with my rusty css. 4 | */ 5 | body { 6 | 7 | } 8 | 9 | #forest { 10 | background: transparent url('/images/dark_forest.jpg') top left no-repeat; 11 | 12 | width: 540px; 13 | height: 450px; 14 | position: relative; 15 | } 16 | 17 | #bear_trap { 18 | position: absolute; 19 | left: 180px; 20 | top: 240px; 21 | width: 220px; 22 | height: 180px; 23 | } 24 | 25 | #cheese { 26 | padding-top: 36px; 27 | padding-left: 60px; 28 | } 29 | 30 | #mouse { 31 | padding-top: 36px; 32 | padding-left: 36px; 33 | } -------------------------------------------------------------------------------- /examples/arc_challenge.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Implementation of the Arc Challenge using Wee. 3 | # 4 | # By Michael Neumann (mneumann@ntecs.de) 5 | # 6 | # http://onestepback.org/index.cgi/Tech/Ruby/ArcChallenge.red 7 | # 8 | 9 | require 'wee' 10 | 11 | class Page1 < Wee::Component 12 | def initialize 13 | add_decoration(Wee::FormDecoration.new) 14 | add_decoration(Wee::PageDecoration.new) 15 | end 16 | 17 | def render(r) 18 | r.text_input.callback {|text| call Page2.new(text)} 19 | r.submit_button.value('OK') 20 | end 21 | end 22 | 23 | class Page2 < Wee::Component 24 | def initialize(text) 25 | @text = text 26 | end 27 | def render(r) 28 | r.anchor.callback { call Page3.new(@text) }.with('click here') 29 | end 30 | end 31 | 32 | class Page3 < Page2 33 | def render(r) 34 | r.text 'You said: ' 35 | r.text @text 36 | r.break 37 | end 38 | end 39 | 40 | Wee.run(Page1) if __FILE__ == $0 41 | -------------------------------------------------------------------------------- /examples/arc_challenge2.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Implementation of the Arc Challenge using Wee. 3 | # 4 | # By Michael Neumann (mneumann@ntecs.de) 5 | # 6 | # http://onestepback.org/index.cgi/Tech/Ruby/ArcChallenge.red 7 | # 8 | 9 | require 'wee' 10 | 11 | class Wee::IO 12 | def initialize(component) 13 | @component = component 14 | end 15 | 16 | def ask 17 | @component.call_inline do |r| 18 | r.form do 19 | text = nil 20 | r.text_input.callback {|t| text = t} 21 | r.submit_button.callback { answer(text) }.value("Enter") 22 | end 23 | end 24 | end 25 | 26 | def pause(text) 27 | @component.call_inline {|r| r.anchor.callback { answer }.with(text) } 28 | end 29 | 30 | def tell(text) 31 | @component.call_inline {|r| r.text text.to_s } 32 | end 33 | end 34 | 35 | class ArcChallenge < Wee::Task 36 | def go 37 | io = Wee::IO.new(self) 38 | text = io.ask 39 | io.pause("click here") 40 | io.tell("You said: #{text}") 41 | end 42 | end 43 | 44 | Wee.run(ArcChallenge) if __FILE__ == $0 45 | -------------------------------------------------------------------------------- /examples/cheese_task.rb: -------------------------------------------------------------------------------- 1 | require 'wee' 2 | 3 | class CheeseTask < Wee::Task 4 | def go 5 | begin choose_cheese end until confirm_cheese 6 | inform_cheese 7 | end 8 | 9 | def choose_cheese 10 | @cheese = nil 11 | while @cheese.nil? 12 | @cheese = choose_from %w(Greyerzer Tilsiter Sbrinz), "What's your favorite Cheese?" 13 | end 14 | end 15 | 16 | def confirm_cheese 17 | confirm "Is #{@cheese} your favorite cheese?" 18 | end 19 | 20 | def inform_cheese 21 | inform "Your favorite is #{@cheese}." 22 | end 23 | end 24 | 25 | Wee.run(CheeseTask) if __FILE__ == $0 26 | -------------------------------------------------------------------------------- /examples/continuations.rb: -------------------------------------------------------------------------------- 1 | require 'wee' 2 | require_relative 'demo/messagebox' 3 | 4 | class MainPage < Wee::Component 5 | 6 | def initialize 7 | super 8 | add_decoration(Wee::PageDecoration.new("Test")) 9 | end 10 | 11 | def click 12 | if callcc Wee::MessageBox.new('Really quit?') 13 | callcc Wee::MessageBox.new('You clicked YES') 14 | else 15 | callcc Wee::MessageBox.new('You clicked Cancel') 16 | callcc Wee::MessageBox.new('super') 17 | end 18 | end 19 | 20 | def render(r) 21 | r.anchor.callback_method(:click).with('show') 22 | end 23 | 24 | end 25 | 26 | Wee.run(MainPage) 27 | -------------------------------------------------------------------------------- /examples/demo.rb: -------------------------------------------------------------------------------- 1 | require 'wee' 2 | 3 | require_relative 'demo/calculator' 4 | require_relative 'demo/counter' 5 | require_relative 'demo/calendar' 6 | require_relative 'demo/radio' 7 | require_relative 'demo/file_upload' 8 | require_relative 'arc_challenge2' 9 | require_relative 'cheese_task' 10 | 11 | class ArcChallengeWrapper < Wee::WrapperDecoration 12 | def global?; true end 13 | 14 | def render(r) 15 | r.paragraph 16 | url = "http://onestepback.org/index.cgi/Tech/Ruby/ArcChallenge.red" 17 | r.anchor.url(url).with(url) 18 | r.paragraph 19 | render_inner(r) 20 | end 21 | end 22 | 23 | class Demo < Wee::RootComponent 24 | class E < Struct.new(:component, :title, :file); end 25 | 26 | def title 27 | 'Wee Demos' 28 | end 29 | 30 | def initialize 31 | @components = [] 32 | @components << E.new(Counter.new, "Counter", 'examples/demo/counter.rb') 33 | @components << E.new(Calculator.new, "Calculator", 'examples/demo/calculator.rb') 34 | @components << E.new(CustomCalendarDemo.new, "Calendar", 'examples/demo/calendar.rb') 35 | @components << E.new(RadioTest.new, "Radio Buttons", 'examples/demo/radio.rb') 36 | @components << E.new(FileUploadTest.new, "File Upload", 'examples/demo/file_upload.rb') 37 | 38 | if $cc 39 | # these components need continuations 40 | arc = ArcChallenge.new 41 | arc.add_decoration(ArcChallengeWrapper.new) 42 | @components << E.new(arc, "Arc Challenge", 'arc_challenge2.rb') 43 | @components << E.new(CheeseTask.new, "Cheese Task", 'cheese_task.rb') 44 | end 45 | 46 | @editor = Editor.new 47 | 48 | select_component(@components.first) 49 | end 50 | 51 | def children 52 | @components.map {|c| c.component} + [@editor] 53 | end 54 | 55 | def select_component(component) 56 | @editor.entry = @selected_component = component 57 | end 58 | 59 | def render(r) 60 | r.form.enctype_multipart.with do 61 | r.h1 'Wee Component Demos' 62 | r.div.style('float: left; width: 200px;').with { 63 | r.select_list(@components). 64 | labels(@components.map {|c| c.title}). 65 | selected(@selected_component). 66 | size(10). 67 | onclick_javascript("this.form.submit()"). 68 | callback_method(:select_component) 69 | r.break 70 | r.checkbox.checked(@editor.visibility). 71 | onclick_javascript("this.form.submit()"). 72 | callback {|bool| @editor.visibility = bool } 73 | r.space 74 | r.text "Show Sourcecode?" 75 | r.break 76 | } 77 | r.div.style('float: left; left: 20px; height: 200px; width: 600px; background: #EFEFEF; border: 1px dotted red; padding: 10px').with { 78 | r.render @selected_component.component 79 | } 80 | r.render @editor 81 | end 82 | end 83 | 84 | class Editor < Wee::Component 85 | attr_accessor :visibility 86 | attr_accessor :entry 87 | 88 | def initialize 89 | super 90 | @visibility = false 91 | @mode = :view 92 | @entry = nil 93 | end 94 | 95 | def save 96 | File.open(@entry.file, 'w+') {|f| f << @txt.lines.map {|l| l.chomp}.join("\n") + "\n" } 97 | load @entry.file 98 | @mode = :view 99 | end 100 | 101 | def render(r) 102 | return unless @visibility 103 | r.div.style('float: left; margin-top: 2em; border-top: 2px solid; width: 100%').with { 104 | if @mode == :view 105 | r.anchor.callback { @mode = :edit }.with('edit') 106 | r.pre.ondblclick_callback { @mode = :edit }.with { 107 | r.encode_text(File.read(@entry.file)) 108 | } 109 | else 110 | r.form do 111 | r.anchor.callback { @mode = :view }.with('cancel'); r.space 112 | r.submit_button.callback_method(:save).value('Save!') 113 | r.break 114 | r.text_area.rows(25).cols(120).callback{|txt| @txt = txt }.with { 115 | r.encode_text(File.read(@entry.file)) 116 | } 117 | end 118 | end 119 | } 120 | end 121 | end 122 | 123 | end 124 | 125 | if __FILE__ == $0 126 | if ARGV[0] == "cc" 127 | $cc = true 128 | Wee.run(Demo) 129 | else 130 | Wee.run(Demo, :use_continuations => false) 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /examples/demo/calculator.rb: -------------------------------------------------------------------------------- 1 | require_relative 'messagebox' 2 | 3 | class Calculator < Wee::Component 4 | def initialize 5 | super() 6 | @number_stack = [] 7 | @input = "" 8 | end 9 | 10 | def state(s) 11 | super 12 | s.add(@number_stack) 13 | s.add(@input) 14 | end 15 | 16 | def render(r) 17 | r.ul { @number_stack.each {|num| r.li(num) } } 18 | 19 | r.text_input.value(@input).readonly 20 | 21 | r.space 22 | 23 | r.submit_button.value("Enter").callback { enter } 24 | r.submit_button.value("C").callback { clear } 25 | 26 | r.break 27 | 28 | (0..9).each {|num| 29 | r.submit_button.value(num.to_s).callback { append(num.to_s) } 30 | } 31 | 32 | r.submit_button.value(".").disabled(@input.include?(".")).callback { append(".") } 33 | 34 | ['+', '-', '*', '/'].each { |op| 35 | r.submit_button.value(op).callback { operation(op) } 36 | } 37 | end 38 | 39 | protected 40 | 41 | def enter 42 | @number_stack << @input.to_f 43 | clear() 44 | end 45 | 46 | def clear 47 | @input.replace("") 48 | end 49 | 50 | def append(str) 51 | @input << str 52 | end 53 | 54 | def operation(op) 55 | enter unless @input.empty? 56 | if @number_stack.size < 2 57 | call Wee::MessageBox.new('Stack underflow!') 58 | else 59 | r2, r1 = @number_stack.pop, @number_stack.pop 60 | @number_stack.push(r1.send(op, r2)) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /examples/demo/calendar.rb: -------------------------------------------------------------------------------- 1 | # Copyright by Kevin Howe (kh@newclear.ca) 2 | 3 | require 'date' 4 | 5 | class Date 6 | # Fetch the number of days in the given month 7 | # 8 | def days_in 9 | ((Date.new(self.year, self.month, 1) >> 1)-1).day 10 | end 11 | # Calendar represenation of a month. Consists of a 12 | # list of weeks, each week a list of 7 days, each day a Date object. 13 | # Padded with days showing for previous and next month. 14 | # 15 | def calendar 16 | # months 17 | curr_month = Date.new(self.year, self.month, 1) 18 | prev_month = (curr_month << 1) 19 | next_month = (curr_month >> 1) 20 | 21 | # previous month days 22 | prev_days = Array.new 23 | prev_in_curr = curr_month.wday 24 | ((curr_month-1)-(prev_in_curr-1)).upto(curr_month-1) { |d| prev_days << d } 25 | 26 | # current month days 27 | curr_days = Array.new 28 | curr_month.upto(next_month-1) { |d| curr_days << d } 29 | 30 | # next month days 31 | next_days = Array.new 32 | days = prev_days+curr_days 33 | weeks = (days.size.to_f/7).ceil 34 | cdays_size = weeks*7 35 | next_in_curr = (cdays_size-days.size) 36 | next_month.upto(next_month+(next_in_curr-1)) { |d| next_days << d } 37 | days += next_days 38 | 39 | # split into weeks 40 | table = Array.new 41 | days.each do |day| 42 | table << Array.new if table.size == 0 or table.last.size == 7 43 | table.last << day 44 | end 45 | 46 | table 47 | end 48 | end 49 | 50 | # Generates a browsable calendar. 51 | # Each day is linked, clicking will set the date to that particular day. 52 | # 53 | class MiniCalendar < Wee::Component 54 | 55 | # Browse mode: no answer will be given 56 | attr_accessor :browse 57 | 58 | # Holds the current chosen date 59 | attr_accessor :date 60 | 61 | # Initialize the MiniCalendar 62 | # 63 | def initialize(date=Date.today) 64 | super() 65 | @month = Date.new(date.year, date.month, 1) 66 | @day = date 67 | @browse = false 68 | end 69 | 70 | # Backtrack state 71 | # 72 | def state(s) 73 | super 74 | s.add(self) 75 | end 76 | 77 | # Set to browse-only (no answer will be given) 78 | # 79 | def browse(value=true) 80 | @browse = (value && true) 81 | self 82 | end 83 | 84 | # True if in browser-only mode 85 | # 86 | def browse? 87 | @browse 88 | end 89 | 90 | # True if the given date is the currently selected month 91 | # 92 | def current_month?(date) 93 | Date.new(date.year, date.month, 1) == @month 94 | end 95 | 96 | # True if the given date is the currently selected day 97 | # 98 | def selected_day?(date) 99 | date == @day 100 | end 101 | 102 | # Date object representing the previous month 103 | # 104 | def prev_month 105 | @month << 1 106 | end 107 | 108 | # Date object representing the next month 109 | # 110 | def next_month 111 | @month >> 1 112 | end 113 | 114 | # Previous month's abbreviation 115 | # 116 | def prev_month_abbr 117 | Date::ABBR_MONTHNAMES[prev_month.month] 118 | end 119 | 120 | # Next month's abbreviation 121 | # 122 | def next_month_abbr 123 | Date::ABBR_MONTHNAMES[next_month.month] 124 | end 125 | 126 | # String to be displayed as the month heading 127 | # 128 | def month_heading 129 | Date::MONTHNAMES[@month.month].to_s+' '+@month.year.to_s 130 | end 131 | 132 | # String to be displayed indicating the current date 133 | # 134 | def today_string 135 | date = Date.today 136 | mon_abbr = Date::ABBR_MONTHNAMES[date.month] 137 | day_abbr = Date::ABBR_DAYNAMES[date.wday] 138 | sprintf('Today is %s, %s %s %s', day_abbr, mon_abbr, date.day, date.year) 139 | end 140 | 141 | # Render a given day 142 | # 143 | def render_day(r, date) 144 | if current_month?(date) 145 | selected_day?(date) ? render_selected_day(r, date) : render_month_day(r, date) 146 | else 147 | render_other_day(r, date) 148 | end 149 | end 150 | 151 | # Render a day of the currently selected month 152 | # 153 | def render_month_day(r, date) 154 | r.table_data { r.anchor.callback { save(date) }.with(date.day) } 155 | end 156 | 157 | # Render the currently selected day 158 | # 159 | def render_selected_day(r, date) 160 | r.table_data.style('border: 1px solid black').with do 161 | r.anchor.style('font-weight: bold').callback { save(date) }.with(date.day) 162 | end 163 | end 164 | 165 | # Render days of the previous or next month 166 | # 167 | def render_other_day(r, date) 168 | r.table_data do 169 | r.anchor.style('color: silver').callback { save(date) }.with(date.day) 170 | end 171 | end 172 | 173 | # CSS styles 174 | # 175 | def render_styles(r) 176 | # ... 177 | end 178 | 179 | # Render Calender header 180 | # 181 | def render_header(r) 182 | r.table_row do 183 | r.table_header.colspan(4).with { r.encode_text(month_heading) } 184 | r.table_header { r.anchor.callback { go_prev }.with(prev_month_abbr) } 185 | r.table_header { r.anchor.callback { go_next }.with(next_month_abbr) } 186 | r.table_header { browse? ? r.space : r.anchor.callback { back }.style('color: black').with('X') } 187 | end 188 | end 189 | 190 | # Render Calendar footer 191 | # 192 | def render_footer(r) 193 | r.table_row { r.table_header.colspan(7).with { r.encode_text(today_string) } } 194 | end 195 | 196 | # Render Calendar 197 | # 198 | def render(r) 199 | render_styles(r) 200 | r.div.css_class("cal").with do 201 | r.text(sprintf('', @month, @day)) 202 | r.table { r.table_row { r.table_header { 203 | r.table do 204 | render_header(r) 205 | r.table_row { Date::ABBR_DAYNAMES.each { |day| r.table_header(day) } } 206 | @month.calendar.each do |week| 207 | r.table_row do 208 | week.each { |day| render_day(r, day) } 209 | end 210 | end 211 | render_footer(r) 212 | end 213 | }}} 214 | end 215 | end 216 | 217 | # Return without changes 218 | # 219 | def back 220 | answer nil unless browse? 221 | end 222 | 223 | # Select the previous month 224 | # 225 | def go_prev 226 | @month = prev_month 227 | end 228 | 229 | # Select the next month 230 | # 231 | def go_next 232 | @month = next_month 233 | end 234 | 235 | # Save the given day 236 | # 237 | def save(day) 238 | @day = day 239 | @month = Date.new(day.year, day.month, 1) 240 | answer(day) unless browse? 241 | end 242 | end 243 | 244 | # Custom CSS styles 245 | # 246 | module StyleMixin 247 | def render_styles(r) 248 | r.style(" 249 | .cal { 250 | font-size : 11px; 251 | font-family : Arial, Helvetica, sans-serif; 252 | text-align: center; 253 | } 254 | .cal a { 255 | text-decoration: none; 256 | } 257 | .cal td { 258 | font-family: Arial, Helvetica, sans-serif; 259 | font-size: 11px; 260 | border: 1px solid; 261 | background-color: #FFFFFF; 262 | vertical-align: top; 263 | text-align: center; 264 | } 265 | .cal th { 266 | font-family: Arial, Helvetica, sans-serif; 267 | font-size: 11px; 268 | font-style: normal; 269 | font-weight: bold; 270 | background-color: #BBCCFF; 271 | border: 1px solid; 272 | vertical-align: top; 273 | text-align: center; 274 | } 275 | ") 276 | end 277 | end 278 | 279 | # Calendar with custom CSS styles 280 | # 281 | class CustomCalendar < MiniCalendar 282 | include StyleMixin 283 | end 284 | 285 | # Calendar demo 286 | # 287 | class CustomCalendarDemo < Wee::Component 288 | include StyleMixin 289 | 290 | # Holds the current chosen date 291 | attr_accessor :date 292 | 293 | # Initialize with a Date object (defaults to today) 294 | # 295 | def initialize(date=Date.today) 296 | super() 297 | @date = date 298 | end 299 | 300 | # Backtrack state 301 | # 302 | def state(s) 303 | super 304 | s.add(self) 305 | end 306 | 307 | # Render calendar icon 308 | # 309 | def render_icon(r) 310 | icon = 'http://www.softcomplex.com/products/tigra_calendar/img/cal.gif' 311 | r.image.src(icon).width(16).height(16).border(0).alt('Calendar') 312 | end 313 | 314 | # Render Calendar demo 315 | # 316 | def render(r) 317 | r.text_input.value(@date.to_s).callback{|val| @date } #@date = val} 318 | r.space 319 | r.anchor.callback { calendar }.with { render_icon(r) } 320 | end 321 | 322 | # Call the calendar component 323 | # 324 | def calendar() 325 | call CustomCalendar.new(@date) do |date| 326 | set_date(date) 327 | end 328 | end 329 | 330 | def set_date(date) 331 | @date = date if date 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /examples/demo/counter.rb: -------------------------------------------------------------------------------- 1 | class Counter < Wee::Component 2 | attr_accessor :count 3 | 4 | def initialize(initial_count=0) 5 | @count = initial_count 6 | add_decoration Wee::StyleDecoration.new(self) 7 | end 8 | 9 | def state(s) super 10 | s.add_ivar(self, :@count) 11 | end 12 | 13 | def dec 14 | @count -= 1 15 | end 16 | 17 | def inc 18 | @count += 1 19 | end 20 | 21 | def style 22 | ".wee-Counter a { border: 1px dotted blue; margin: 2px; }" 23 | end 24 | 25 | def render(r) 26 | r.div.oid.css_class('wee-Counter').with { 27 | r.anchor.callback_method(:dec).with("--") 28 | r.space 29 | render_count(r) 30 | r.space 31 | r.anchor.callback_method(:inc).with("++") 32 | } 33 | end 34 | 35 | def render_count(r) 36 | r.text @count.to_s 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /examples/demo/editable_counter.rb: -------------------------------------------------------------------------------- 1 | # NEEDS: FormDecoration 2 | 3 | require_relative 'counter' 4 | 5 | class EditableCounter < Counter 6 | 7 | def initialize(initial_count=0) 8 | super 9 | @show_edit_field = false 10 | end 11 | 12 | def state(s) 13 | super 14 | s.add_ivar(self, :@show_edit_field, @show_edit_field) 15 | end 16 | 17 | def render_count(r) 18 | if @show_edit_field 19 | r.text_input.value(@count).size(6).callback {|val| self.count = val} 20 | r.submit_button.value('S').callback { submit } 21 | else 22 | r.anchor.callback { submit }.with(@count) 23 | end 24 | end 25 | 26 | def submit 27 | if @count.to_s !~ /^\d+$/ 28 | call Wee::MessageBox.new("You entered an invalid counter! Please try again!") 29 | @count = 0 30 | else 31 | @show_edit_field = !@show_edit_field 32 | end 33 | @count = @count.to_i 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /examples/demo/example.rb: -------------------------------------------------------------------------------- 1 | require 'wee' 2 | require_relative 'window' 3 | require_relative 'editable_counter' 4 | require_relative 'messagebox' 5 | 6 | class RegexpValidatedInput < Wee::Component 7 | 8 | def initialize(init_value, regexp) 9 | super() 10 | @regexp = regexp 11 | self.value = init_value 12 | @error = false 13 | end 14 | 15 | def state(s) 16 | super 17 | s.add(self) 18 | end 19 | 20 | def value 21 | @value 22 | end 23 | 24 | def value=(new_value) 25 | raise unless new_value =~ @regexp 26 | @input = @value = new_value 27 | end 28 | 29 | def render(r) 30 | r.form do 31 | r.text_input.value(@input).callback {|val| self.input = val } 32 | r.text %(
Invalid input
) if @error 33 | end 34 | end 35 | 36 | def input 37 | @input 38 | end 39 | 40 | def input=(str) 41 | @input = str 42 | 43 | if @input =~ @regexp 44 | @value = str 45 | @error = false 46 | else 47 | @error = true 48 | end 49 | end 50 | 51 | end 52 | 53 | class MainPage < Wee::Component 54 | def initialize 55 | super() 56 | @counters = (1..10).map {|i| 57 | Window.new {|w| 58 | w.title = "Cnt #{ i }" 59 | w.pos_x = "200px" 60 | w.pos_y = "#{i*50}px" 61 | w << EditableCounter.new(i) 62 | } 63 | } 64 | 65 | @val_inp = RegexpValidatedInput.new('Michael Neumann', /^\w+\s+\w+$/) 66 | 67 | @arr = [] 68 | @text = "" 69 | 70 | @list1 = (0..9).to_a 71 | @selected1 = [] 72 | @list2 = [] 73 | @selected2 = [] 74 | end 75 | 76 | def children 77 | [@val_inp, *@counters] 78 | end 79 | 80 | def state(s) 81 | super 82 | s.add(@counters) 83 | state_decoration(s) 84 | s.add(@arr) 85 | s.add(@text) 86 | 87 | s.add(@list1) 88 | s.add(@selected1) 89 | s.add(@list2) 90 | s.add(@selected2) 91 | end 92 | 93 | attr_accessor :text 94 | 95 | def render(r) 96 | r.page.title("Counter Test").with do 97 | 98 | r.form do 99 | r.select_list(@list1).size(10).multiple.selected(@selected1).callback {|choosen| @selected1.replace(choosen)} 100 | r.submit_button.value('->').callback { @list2.push(*@selected1); @list1.replace(@list1-@selected1); @selected1.replace([]) } 101 | r.submit_button.value('<-').callback { @list1.push(*@selected2); @list2.replace(@list2-@selected2); @selected2.replace([]) } 102 | r.select_list(@list2).size(10).multiple.selected(@selected2).callback {|choosen| @selected2.replace(choosen)} 103 | end 104 | 105 | r.form do 106 | 107 | @counters.each { |cnt| 108 | r.render(cnt) 109 | } 110 | 111 | r.render(@val_inp) 112 | 113 | @arr.each do |a| 114 | r.text(a) 115 | r.break 116 | end 117 | 118 | end 119 | 120 | r.form do 121 | r.text_input.value(@text).callback{|val| @text = val} 122 | r.submit_button.callback{add}.value('add') 123 | end 124 | 125 | end 126 | end 127 | 128 | def add 129 | call Wee::MessageBox.new("Do you really want to add '" + @text + "'?") do |res| 130 | if res 131 | call Wee::MessageBox.new("Do you really really really want to add '" + @text + "'?") do |res2| 132 | @arr << @text if res2 133 | end 134 | end 135 | end 136 | end 137 | end 138 | 139 | Wee.run(MainPage) if __FILE__ == $0 140 | -------------------------------------------------------------------------------- /examples/demo/file_upload.rb: -------------------------------------------------------------------------------- 1 | class FileUploadTest < Wee::Component 2 | def render(r) 3 | r.file_upload.callback {|f| call Uploaded.new(f[:tempfile]) } 4 | r.break 5 | r.submit_button.name('Upload') 6 | end 7 | 8 | class Uploaded < Wee::Component 9 | def initialize(file) 10 | super() 11 | @file = file 12 | end 13 | 14 | def render(r) 15 | r.pre { r.encode_text @file.read } 16 | r.anchor.callback_method(:answer).with('back') 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /examples/demo/messagebox.rb: -------------------------------------------------------------------------------- 1 | class Wee::MessageBox < Wee::Component 2 | def initialize(text) 3 | super() 4 | @text = text 5 | end 6 | 7 | def render(r) 8 | r.bold(@text) 9 | r.form do 10 | r.submit_button.value('OK').callback { answer true } 11 | r.space 12 | r.submit_button.value('Cancel').callback { answer false } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /examples/demo/radio.rb: -------------------------------------------------------------------------------- 1 | class RadioTest < Wee::Component 2 | def render(r) 3 | grp1 = r.new_radio_group 4 | grp2 = r.new_radio_group 5 | 6 | r.paragraph 7 | r.text "Group1" 8 | r.text " (your choice: #{@g1})" if @g1 9 | r.break 10 | 11 | r.text "R1: " 12 | r.radio_button.group(grp1).checked(@g1.nil? || @g1 == 'R1').callback { @g1 = 'R1' } 13 | r.break 14 | 15 | r.text "R2: " 16 | r.radio_button.group(grp1).checked(@g1 == 'R2').callback { @g1 = 'R2' } 17 | 18 | r.paragraph 19 | r.text "Group2" 20 | r.text " (your choice: #{@g2})" if @g2 21 | r.break 22 | 23 | r.text "R1: " 24 | r.radio_button.group(grp2).checked(@g2.nil? || @g2 == 'R1').callback { @g2 = 'R1' } 25 | r.break 26 | 27 | r.text "R2: " 28 | r.radio_button.group(grp2).checked(@g2 == 'R2').callback { @g2 = 'R2' } 29 | 30 | r.paragraph 31 | r.submit_button.value('Submit') 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /examples/demo/window.rb: -------------------------------------------------------------------------------- 1 | class Window < Wee::Component 2 | 3 | attr_accessor :title, :pos_x, :pos_y 4 | 5 | def initialize(&block) 6 | super() 7 | @status = :normal 8 | @pos_x, @pos_y = "0px", "0px" 9 | @children = [] 10 | block.call(self) if block 11 | end 12 | 13 | def <<(c) 14 | @children << c 15 | end 16 | 17 | def children() @children end 18 | 19 | def state(s) 20 | super 21 | s.add_ivar(self, :@status, @status) 22 | end 23 | 24 | def process_callbacks(callbacks) 25 | return if @status == :closed 26 | super 27 | end 28 | 29 | def render(r) 30 | return if @status == :closed 31 | 32 | r.table.cellspacing(0).style("border:solid 1px grey; position: absolute; left: #{@pos_x}; top: #{@pos_y};").with do 33 | r.table_row.style("background-color: lightblue; width: 100%").with do 34 | r.table_data.style("text-align: left; width: 66%").with(@title) 35 | r.table_data.style("text-align: right").with do 36 | if @status == :minimized 37 | r.anchor.callback{maximize}.with("^") 38 | else 39 | r.anchor.callback{minimize}.with("_") 40 | end 41 | r.space 42 | r.anchor.callback{close}.with("x") 43 | end 44 | end 45 | r.table_row do 46 | r.table_data.colspan(2).with do 47 | if @status == :normal 48 | for child in self.children do 49 | r.render(child) 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | 57 | public 58 | 59 | def minimize 60 | @status = :minimized 61 | end 62 | 63 | def maximize 64 | @status = :normal 65 | end 66 | 67 | def close 68 | @status = :closed 69 | end 70 | 71 | end 72 | -------------------------------------------------------------------------------- /examples/hw.rb: -------------------------------------------------------------------------------- 1 | require 'wee' 2 | 3 | class HelloWorld < Wee::RootComponent 4 | def render(r) 5 | r.h1 "Hello World from Wee!" 6 | end 7 | end 8 | 9 | Wee.run(HelloWorld) if __FILE__ == $0 10 | -------------------------------------------------------------------------------- /examples/i18n/app.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift "../../lib" 2 | require 'rubygems' 3 | require 'wee' 4 | require 'wee/locale' 5 | 6 | class HelloWorld < Wee::RootComponent 7 | def render(r) 8 | r.h1 _("Hello World!") 9 | r.select_list(%w(en de)).selected(session.locale).labels(["English", "Deutsch"]).callback {|lang| session.locale = lang} 10 | r.submit_button.value(_("Set")) 11 | end 12 | end 13 | 14 | Wee::Application.load_locale("app", %w(en de), "en", :path => "locale", :type => :po) 15 | 16 | HelloWorld.run if __FILE__ == $0 17 | -------------------------------------------------------------------------------- /examples/i18n/locale/de/app.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "POT-Creation-Date: 2010-01-05 00:27+0100\n" 11 | "PO-Revision-Date: 2010-01-05 00:27+0100\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" 18 | 19 | #: app.rb:8 20 | msgid "Hello World!" 21 | msgstr "Hallo Welt!" 22 | 23 | #: app.rb:10 24 | msgid "Set" 25 | msgstr "Einstellen" 26 | -------------------------------------------------------------------------------- /examples/i18n/locale/en/app.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "POT-Creation-Date: 2010-01-05 00:27+0100\n" 11 | "PO-Revision-Date: 2010-01-05 00:27+0100\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" 18 | 19 | #: app.rb:8 20 | msgid "Hello World!" 21 | msgstr "" 22 | 23 | #: app.rb:10 24 | msgid "Set" 25 | msgstr "" 26 | -------------------------------------------------------------------------------- /examples/pager.rb: -------------------------------------------------------------------------------- 1 | class Pager < Wee::Component 2 | attr_accessor :num_entries, :entries_per_page 3 | attr_reader :current_page 4 | 5 | def initialize(num_entries=0) 6 | super() 7 | @num_entries = num_entries 8 | @current_page = 0 9 | @entries_per_page = 20 10 | yield self if block_given? 11 | end 12 | 13 | # Returns the number of pages 14 | 15 | def num_pages 16 | n, rest = @num_entries.divmod(@entries_per_page) 17 | if rest > 0 then n + 1 else n end 18 | end 19 | 20 | # Returns the index of the first entry on the current page 21 | 22 | def current_start_index 23 | @current_page * @entries_per_page 24 | end 25 | 26 | # Returns the index of the last page 27 | 28 | def last_page_index 29 | num_pages() - 1 30 | end 31 | 32 | # Go to first page 33 | 34 | def first 35 | goto(0) 36 | end 37 | 38 | # Go to last page 39 | 40 | def last 41 | goto(last_page_index()) 42 | end 43 | 44 | # Go to previous page 45 | 46 | def prev 47 | goto(@current_page - 1) 48 | end 49 | 50 | # Go to next page 51 | 52 | def next 53 | goto(@current_page + 1) 54 | end 55 | 56 | # Go to page with index +page+ 57 | # Note that page-indices start with zero! 58 | 59 | def goto(page) 60 | @current_page = page 61 | validate 62 | end 63 | 64 | def render(r) 65 | return if num_pages() <= 0 66 | render_arrow(r, :first, "<<", "Go to first page"); r.space(2) 67 | render_arrow(r, :prev, "<", "Go to previous page"); r.space(2) 68 | render_index(r); r.space(2) 69 | render_arrow(r, :next, ">", "Go to next page"); r.space(2) 70 | render_arrow(r, :last, ">>", "Go to last page") 71 | end 72 | 73 | private 74 | 75 | def render_arrow(r, sym, text, tooltip=text) 76 | r.anchor.callback_method(sym).tooltip(tooltip).with { r.encode_text(text) } 77 | end 78 | 79 | def render_index(r) 80 | last = last_page_index() 81 | (0 .. last).each do |i| 82 | if i == @current_page 83 | render_page_num(r, i, true) 84 | else 85 | render_page_num(r, i, false) 86 | end 87 | r.space if i < last 88 | end 89 | end 90 | 91 | def render_page_num(r, num, current) 92 | if current 93 | r.bold(num+1) 94 | else 95 | r.anchor.callback{ goto(num) }.with(num+1) 96 | end 97 | end 98 | 99 | def validate 100 | @current_page = [[0, @current_page].max, last_page_index()].min 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/wee.rb: -------------------------------------------------------------------------------- 1 | require 'wee/version' 2 | require 'wee/state' 3 | require 'wee/callback' 4 | 5 | require 'wee/presenter' 6 | require 'wee/decoration' 7 | require 'wee/component' 8 | require 'wee/root_component' 9 | require 'wee/task' 10 | require 'wee/dialog' 11 | 12 | require 'wee/application' 13 | require 'wee/request' 14 | require 'wee/response' 15 | require 'wee/session' 16 | 17 | require 'wee/html_document' 18 | require 'wee/html_brushes' 19 | require 'wee/html_canvas' 20 | 21 | require 'continuation' 22 | require 'wee/hello_world' 23 | require 'wee/run' 24 | -------------------------------------------------------------------------------- /lib/wee/application.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'wee/id_generator' 3 | require 'wee/lru_cache' 4 | 5 | module Wee 6 | 7 | # 8 | # A Wee::Application manages all Session's of a single application. It 9 | # dispatches the request to the correct handler by examining the request. 10 | # 11 | class Application 12 | 13 | def self.for(component_class, session_class=Wee::Session, *component_args) 14 | new { session_class.new(component_class.new(*component_args)) } 15 | end 16 | 17 | class SessionCache < Wee::LRUCache 18 | def garbage_collect 19 | delete_if {|id, session| session.dead? } 20 | end 21 | end 22 | 23 | # 24 | # Creates a new application. The block, when called, must 25 | # return a new Session instance. 26 | # 27 | # Wee::Application.new { Wee::Session.new(root_component) } 28 | # 29 | def initialize(max_sessions=10_000, &block) 30 | @session_factory = block || raise(ArgumentError) 31 | @session_ids ||= Wee::IdGenerator::Secure.new 32 | @sessions = SessionCache.new(max_sessions) 33 | @mutex = Mutex.new 34 | end 35 | 36 | # 37 | # Garbage collect dead sessions 38 | # 39 | def cleanup_sessions 40 | @mutex.synchronize { @sessions.garbage_collect } 41 | end 42 | 43 | # 44 | # Handles a web request 45 | # 46 | def call(env) 47 | request = Wee::Request.new(env) 48 | 49 | if request.session_id 50 | session = @mutex.synchronize { @sessions.fetch(request.session_id) } 51 | if session and session.alive? 52 | session.call(env) 53 | else 54 | url = request.build_url(:session_id => nil, :page_id => nil) 55 | Wee::RefreshResponse.new("Invalid or expired session", url).finish 56 | end 57 | else 58 | session = new_session() 59 | url = request.build_url(:session_id => session.id, :page_id => nil) 60 | Wee::RedirectResponse.new(url).finish 61 | end 62 | end 63 | 64 | protected 65 | 66 | def new_session 67 | session = @session_factory.call 68 | session.application = self 69 | insert_session(session) 70 | return session 71 | end 72 | 73 | def insert_session(session, retries=3) 74 | retries.times do 75 | @mutex.synchronize { 76 | id = @session_ids.next 77 | if not @sessions.has_key?(id) 78 | @sessions.store(id, session) 79 | session.id = id 80 | return 81 | end 82 | } 83 | end 84 | raise 85 | end 86 | 87 | end # class Application 88 | 89 | end # module Wee 90 | -------------------------------------------------------------------------------- /lib/wee/callback.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | class CallbackRegistry 4 | def initialize(prefix="") 5 | @prefix = prefix 6 | @next_id = 0 7 | @callbacks = {} # {callback_id1 => callback1, callback_id2 => callback2} 8 | @triggered = nil 9 | @obj_map = {} # obj => [callback_id1, callback_id2, ...] 10 | end 11 | 12 | def empty? 13 | @callbacks.empty? 14 | end 15 | 16 | def register(object, callback) 17 | id = @next_id 18 | @next_id += 1 19 | @callbacks[id] = callback 20 | (@obj_map[object] ||= []) << id 21 | return "#{@prefix}#{id}" 22 | end 23 | 24 | def unregister(object) 25 | if arr = @obj_map.delete(object) 26 | arr.each {|id| @callbacks.delete(id) } 27 | end 28 | end 29 | 30 | # 31 | # NOTE that if fields named "xxx" and "xxx.yyy" occur, the value of 32 | # @fields['xxx'] is { nil => ..., 'yyy' => ... }. This is required 33 | # to make image buttons work correctly. 34 | # 35 | def prepare_triggered(ids_and_values) 36 | @triggered = {} 37 | ids_and_values.each do |id, value| 38 | if id =~ /^#{@prefix}(\d+)([.](.*))?$/ 39 | id, suffix = Integer($1), $3 40 | next unless @callbacks[id] 41 | 42 | if @triggered[id].kind_of?(Hash) 43 | @triggered[id][suffix] = value 44 | elsif suffix 45 | @triggered[id] = {nil => @triggered[id], suffix => value} 46 | else 47 | @triggered[id] = value 48 | end 49 | end 50 | end 51 | end 52 | 53 | def reset_triggered 54 | @triggered = nil 55 | end 56 | 57 | def each_triggered(object) 58 | if ary = @obj_map[object] 59 | for id in ary 60 | yield @callbacks[id], @triggered[id] if @triggered.has_key?(id) 61 | end 62 | end 63 | end 64 | 65 | def each_triggered_call_with_value(object) 66 | if ary = @obj_map[object] 67 | for id in ary 68 | @callbacks[id].call(@triggered[id]) if @triggered.has_key?(id) 69 | end 70 | end 71 | end 72 | 73 | def first_triggered(object) 74 | if ary = @obj_map[object] 75 | for id in ary 76 | return @callbacks[id] if @triggered.has_key?(id) 77 | end 78 | end 79 | return nil 80 | end 81 | 82 | end # class CallbackRegistry 83 | 84 | class Callbacks 85 | attr_reader :input_callbacks 86 | attr_reader :action_callbacks 87 | 88 | def initialize 89 | @input_callbacks = CallbackRegistry.new("") 90 | @action_callbacks = CallbackRegistry.new("a") 91 | end 92 | 93 | def unregister(object) 94 | @input_callbacks.unregister(object) 95 | @action_callbacks.unregister(object) 96 | end 97 | 98 | def with_triggered(ids_and_values) 99 | @input_callbacks.prepare_triggered(ids_and_values) 100 | @action_callbacks.prepare_triggered(ids_and_values) 101 | yield 102 | ensure 103 | @input_callbacks.reset_triggered 104 | @action_callbacks.reset_triggered 105 | end 106 | 107 | end # class Callbacks 108 | 109 | end # module Wee 110 | -------------------------------------------------------------------------------- /lib/wee/component.rb: -------------------------------------------------------------------------------- 1 | require 'wee/presenter' 2 | require 'wee/decoration' 3 | 4 | module Wee 5 | 6 | # 7 | # The base class of all components. You should at least overwrite method 8 | # #render in your own subclasses. 9 | # 10 | class Component < Presenter 11 | 12 | # 13 | # Constructs a new instance of the component. 14 | # 15 | # Overwrite this method when you want to use it both as a root component 16 | # and as a non-root component. Here you can add neccessary decorations 17 | # when used as root component, as for example a PageDecoration or a 18 | # FormDecoration. 19 | # 20 | # By default this methods adds no decoration. 21 | # 22 | # See also class RootComponent. 23 | # 24 | def self.instanciate(*args, &block) 25 | new(*args, &block) 26 | end 27 | 28 | # 29 | # Return an array of classes onto which the current component depends. 30 | # Right now this is only used to determine the required ExternalResources. 31 | # 32 | def self.depends 33 | [] 34 | end 35 | 36 | # 37 | # Initializes a newly created component. 38 | # 39 | def initialize 40 | end 41 | 42 | # 43 | # This method renders the content of the component. 44 | # 45 | # *OVERWRITE* this method in your own component classes to implement the 46 | # view. By default this method does nothing! 47 | # 48 | # [+r+] 49 | # An instance of class renderer_class() 50 | # 51 | def render(r) 52 | end 53 | 54 | # 55 | # Take snapshots of objects that should correctly be backtracked. 56 | # 57 | # Backtracking means that you can go back in time of the components' state. 58 | # Therefore it is neccessary to take snapshots of those objects that want to 59 | # participate in backtracking. Taking snapshots of the whole component tree 60 | # would be too expensive and unflexible. Note that methods 61 | # take_snapshot and restore_snapshot are called for those 62 | # objects to take the snapshot (they behave like marshal_dump and 63 | # marshal_load). Overwrite them if you want to define special 64 | # behaviour. 65 | # 66 | # By default only the decoration chain is backtracked. This is 67 | # required to correctly backtrack called components. To disable 68 | # backtracking of the decorations, change method 69 | # Component#state_decoration to a no-operation: 70 | # 71 | # def state_decoration(s) 72 | # # nothing here 73 | # end 74 | # 75 | # [+s+] 76 | # An object of class State 77 | # 78 | def state(s) 79 | state_decoration(s) 80 | for child in self.children 81 | child.decoration.state(s) 82 | end 83 | end 84 | 85 | NO_CHILDREN = [].freeze 86 | # 87 | # Return all child components. 88 | # 89 | # *OVERWRITE* this method and return all child components 90 | # collected in an array. 91 | # 92 | def children 93 | return NO_CHILDREN 94 | end 95 | 96 | # 97 | # Process and invoke all input callbacks specified for this component 98 | # and all of it's child components. 99 | # 100 | # Returns the action callback to be invoked. 101 | # 102 | def process_callbacks(callbacks) 103 | callbacks.input_callbacks.each_triggered_call_with_value(self) 104 | 105 | action_callback = nil 106 | 107 | # process callbacks of all children 108 | for child in self.children 109 | if act = child.decoration.process_callbacks(callbacks) 110 | raise "Duplicate action callback" if action_callback 111 | action_callback = act 112 | end 113 | end 114 | 115 | if act = callbacks.action_callbacks.first_triggered(self) 116 | raise "Duplicate action callback" if action_callback 117 | action_callback = act 118 | end 119 | 120 | return action_callback 121 | end 122 | 123 | def state_decoration(s) 124 | s.add_ivar(self, :@decoration, @decoration) 125 | end 126 | 127 | protected :state_decoration 128 | 129 | # ------------------------------------------------------------- 130 | # Decoration Methods 131 | # ------------------------------------------------------------- 132 | 133 | def decoration=(d) @decoration = d end 134 | def decoration() @decoration || self end 135 | 136 | # 137 | # Iterates over all decorations 138 | # (note that the component itself is excluded) 139 | # 140 | def each_decoration # :yields: decoration 141 | d = @decoration 142 | while d and d != self 143 | yield d 144 | d = d.next 145 | end 146 | end 147 | 148 | # 149 | # Searches a decoration in the decoration chain 150 | # 151 | def find_decoration 152 | each_decoration {|d| yield d and return d } 153 | return nil 154 | end 155 | 156 | # 157 | # Adds decoration +d+ to the decoration chain. 158 | # 159 | # A global decoration is added in front of the decoration chain, a local 160 | # decoration is added in front of all other local decorations but after all 161 | # global decorations. 162 | # 163 | # Returns: +self+ 164 | # 165 | def add_decoration(d) 166 | if d.global? 167 | d.next = self.decoration 168 | self.decoration = d 169 | else 170 | last_global = nil 171 | each_decoration {|i| 172 | if i.global? 173 | last_global = i 174 | else 175 | break 176 | end 177 | } 178 | if last_global.nil? 179 | # no global decorations specified -> add in front 180 | d.next = self.decoration 181 | self.decoration = d 182 | else 183 | # add after last_global 184 | d.next = last_global.next 185 | last_global.next = d 186 | end 187 | end 188 | 189 | return self 190 | end 191 | 192 | # 193 | # Remove decoration +d+ from the decoration chain. 194 | # 195 | # Returns the removed decoration or +nil+ if it did not exist in the 196 | # decoration chain. 197 | # 198 | def remove_decoration(d) 199 | if d == self.decoration # 'd' is in front 200 | self.decoration = d.next 201 | else 202 | last_decoration = self.decoration 203 | next_decoration = nil 204 | loop do 205 | return nil if last_decoration == self or last_decoration.nil? 206 | next_decoration = last_decoration.next 207 | break if d == next_decoration 208 | last_decoration = next_decoration 209 | end 210 | last_decoration.next = d.next 211 | end 212 | d.next = nil # decoration 'd' no longer is an owner of anything! 213 | return d 214 | end 215 | 216 | # 217 | # Remove all decorations that match the block condition. 218 | # 219 | # Example (removes all decorations of class +HaloDecoration+): 220 | # 221 | # remove_decoration_if {|d| d.class == HaloDecoration} 222 | # 223 | def remove_decoration_if # :yields: decoration 224 | to_remove = [] 225 | each_decoration {|d| to_remove << d if yield d} 226 | to_remove.each {|d| remove_decoration(d)} 227 | end 228 | 229 | # ------------------------------------------------------------- 230 | # Call/Answer Methods 231 | # ------------------------------------------------------------- 232 | 233 | # 234 | # Call another component (without using continuations). The calling 235 | # component is neither rendered nor are it's callbacks processed 236 | # until the called component answers using method #answer. 237 | # 238 | # [+component+] 239 | # The component to be called. 240 | # 241 | # [+return_callback+] 242 | # Is invoked when the called component answers. 243 | # 244 | # How it works 245 | # 246 | # The component to be called is wrapped with an AnswerDecoration and a 247 | # Delegate decoration. The latter is used to redirect to the called 248 | # component. Once the decorations are installed, we end the processing of 249 | # callbacks prematurely. 250 | # 251 | # When at a later point in time the called component invokes #answer, this 252 | # will raise a AnswerDecoration::Answer exception which is catched by the 253 | # AnswerDecoration we installed before calling this component, and as such, 254 | # whose process_callbacks method was called before we gained control. 255 | # 256 | # The AnswerDecoration then invokes the answer_callback to cleanup 257 | # the decorations we added during #call and finally passes control to the 258 | # return_callback. 259 | # 260 | def call(component, &return_callback) 261 | delegate = Delegate.new(component) 262 | answer = AnswerDecoration.new 263 | answer.answer_callback = UnwindCall.new(self, component, delegate, answer, &return_callback) 264 | add_decoration(delegate) 265 | component.add_decoration(answer) 266 | session.send_response(nil) 267 | end 268 | 269 | protected :call 270 | 271 | # 272 | # Reverts the changes made due to Component#call. Is called when 273 | # Component#call 'answers'. 274 | # 275 | class UnwindCall 276 | def initialize(calling, called, delegate, answer, &return_callback) 277 | @calling, @called, @delegate, @answer = calling, called, delegate, answer 278 | @return_callback = return_callback 279 | end 280 | 281 | def call(answ) 282 | @calling.remove_decoration(@delegate) 283 | @called.remove_decoration(@answer) 284 | @return_callback.call(*answ.args) if @return_callback 285 | end 286 | end 287 | 288 | # 289 | # Similar to method #call, but using continuations. 290 | # 291 | def callcc(component) 292 | delegate = Delegate.new(component) 293 | answer = AnswerDecoration.new 294 | 295 | add_decoration(delegate) 296 | component.add_decoration(answer) 297 | 298 | answ = Kernel.callcc {|cc| 299 | answer.answer_callback = cc 300 | session.send_response(nil) 301 | } 302 | remove_decoration(delegate) 303 | component.remove_decoration(answer) 304 | 305 | args = answ.args 306 | case args.size 307 | when 0 308 | return 309 | when 1 310 | return args.first 311 | else 312 | return *args 313 | end 314 | end 315 | 316 | protected :callcc 317 | 318 | # 319 | # Chooses one of #call or #callcc depending on whether a block is 320 | # given or not. 321 | # 322 | def call!(comp, &block) 323 | if block 324 | call comp, &block 325 | else 326 | callcc comp 327 | end 328 | end 329 | 330 | protected :call! 331 | 332 | def call_inline(&render_block) 333 | callcc BlockComponent.new(&render_block) 334 | end 335 | 336 | protected :call_inline 337 | 338 | # 339 | # Return from a called component. 340 | # 341 | # NOTE that #answer never returns. 342 | # 343 | # See #call for a detailed description of the call/answer mechanism. 344 | # 345 | def answer(*args) 346 | raise AnswerDecoration::Answer.new(args) 347 | end 348 | 349 | protected :answer 350 | 351 | end # class Component 352 | 353 | class BlockComponent < Component 354 | def initialize(&block) 355 | @block = block 356 | end 357 | 358 | def render(r) 359 | instance_exec(r, &@block) 360 | end 361 | end # class BlockComponent 362 | 363 | end # module Wee 364 | -------------------------------------------------------------------------------- /lib/wee/decoration.rb: -------------------------------------------------------------------------------- 1 | require 'wee/presenter' 2 | 3 | module Wee 4 | 5 | # 6 | # Abstract base class of all decorations. Forwards the methods 7 | # #process_callbacks, #render! and #state to the next decoration in 8 | # the chain. Subclasses should provide special behaviour in these methods, 9 | # otherwise the decoration does not make sense. 10 | # 11 | # For example, a HeaderFooterDecoration class could draw a header and footer 12 | # around the decorations or components below itself: 13 | # 14 | # class HeaderFooterDecoration < Wee::Decoration 15 | # alias render! render_presenter! 16 | # def render(r) 17 | # r.text "header" 18 | # r.render_decoration(@next) 19 | # r.text "footer" 20 | # end 21 | # end 22 | # 23 | class Decoration < Presenter 24 | 25 | # 26 | # Points to the next decoration in the chain. A decoration is responsible for 27 | # all decorations or components "below" it (everything that follows this 28 | # decoration in the chain). In other words, it's the owner of everything 29 | # "below" itself. 30 | # 31 | attr_accessor :next 32 | 33 | # 34 | # Is this decoration a global or a local one? By default all decorations are 35 | # local unless this method is overwritten. 36 | # 37 | # A global decoration is added in front of the decoration chain, a local 38 | # decoration is added in front of all other local decorations but after all 39 | # global decorations. 40 | # 41 | def global?() false end 42 | 43 | # 44 | # Forwards method call to the next decoration in the chain. 45 | # 46 | def process_callbacks(callbacks) 47 | @next.process_callbacks(callbacks) 48 | end 49 | 50 | alias render_presenter! render! 51 | # 52 | # Forwards method call to the next decoration in the chain. 53 | # 54 | def render!(r) 55 | @next.render!(r) 56 | end 57 | 58 | # 59 | # We have to save the @next attribute to be able to correctly backtrack 60 | # calls, as method Wee::Component#call modifies it in the call to 61 | # component.remove_decoration(answer). Removing the 62 | # answer-decoration has the advantage to be able to call a component more 63 | # than once! 64 | # 65 | def state(s) 66 | @next.state(s) 67 | s.add_ivar(self, :@next, @next) 68 | end 69 | 70 | end # class Decoration 71 | 72 | # 73 | # A Wee::Delegate breaks the decoration chain and forwards the methods 74 | # #process_callbacks, #render! and #state to the corresponding *chain* 75 | # method of it's _delegate_ component (a Wee::Component). 76 | # 77 | class Delegate < Decoration 78 | 79 | def initialize(delegate) 80 | @delegate = delegate 81 | end 82 | 83 | # 84 | # Forwards method to the corresponding top-level *chain* method of the 85 | # _delegate_ component. 86 | # 87 | def process_callbacks(callbacks) 88 | @delegate.decoration.process_callbacks(callbacks) 89 | end 90 | 91 | # 92 | # Forwards method to the corresponding top-level *chain* method of the 93 | # _delegate_ component. 94 | # 95 | def render!(r) 96 | @delegate.decoration.render!(r) 97 | end 98 | 99 | # 100 | # Forwards method to the corresponding top-level *chain* method of the 101 | # _delegate_ component. We also take snapshots of all non-visible 102 | # components, thus we follow the @next decoration (via super). 103 | # 104 | def state(s) 105 | super 106 | @delegate.decoration.state(s) 107 | end 108 | 109 | end # class Delegate 110 | 111 | # 112 | # A Wee::AnswerDecoration is wrapped around a component that will call 113 | # Component#answer. This makes it possible to use such components without the 114 | # need to call them (Component#call), e.g. as child components of other 115 | # components. 116 | # 117 | class AnswerDecoration < Decoration 118 | 119 | # 120 | # Used to unwind the component call chain in Component#answer. 121 | # 122 | class Answer < Exception 123 | attr_reader :args 124 | def initialize(args) @args = args end 125 | end 126 | 127 | attr_accessor :answer_callback 128 | 129 | class Interceptor 130 | attr_accessor :action_callback, :answer_callback 131 | 132 | def initialize(action_callback, answer_callback) 133 | @action_callback, @answer_callback = action_callback, answer_callback 134 | end 135 | 136 | def call 137 | @action_callback.call 138 | rescue Answer => answer 139 | # return to the calling component 140 | @answer_callback.call(answer) 141 | end 142 | end 143 | 144 | # 145 | # When a component answers, @answer_callback.call(answer) 146 | # will be executed, where +answer+ is of class Answer which includes the 147 | # arguments passed to Component#answer. 148 | # 149 | def process_callbacks(callbacks) 150 | if action_callback = super 151 | Interceptor.new(action_callback, @answer_callback) 152 | else 153 | nil 154 | end 155 | end 156 | 157 | end # class AnswerDecoration 158 | 159 | 160 | class WrapperDecoration < Decoration 161 | 162 | alias render! render_presenter! 163 | 164 | # 165 | # Overwrite this method, and call render_inner(r) 166 | # where you want the inner content to be drawn. 167 | # 168 | def render(r) 169 | render_inner(r) 170 | end 171 | 172 | def render_inner(r) 173 | r.render_decoration(@next) 174 | end 175 | 176 | end # class WrapperDecoration 177 | 178 | 179 | # 180 | # Renders a
tag with a unique "id" around the wrapped component. 181 | # Useful for components that want to update their content in-place using 182 | # AJAX. 183 | # 184 | class OidDecoration < WrapperDecoration 185 | def render(r) 186 | r.div.oid.with { render_inner(r) } 187 | end 188 | end # class OidDecoration 189 | 190 | # 191 | # Renders a CSS style for a component class. 192 | # 193 | # Only works when used together with a PageDecoration, 194 | # or an existing :styles divert location. 195 | # 196 | # The style is not rendered when in an AJAX request. 197 | # This is the desired behaviour as it is assumed that 198 | # a component is first rendered via a regular request 199 | # and then updated via AJAX requests. 200 | # 201 | # It is only rendered once for all instances of a given 202 | # component. 203 | # 204 | # A method #style must exist returning the CSS style. 205 | # 206 | class StyleDecoration < WrapperDecoration 207 | def initialize(component) 208 | @component = component 209 | end 210 | 211 | def render(r) 212 | r.render_style(@component) 213 | render_inner(r) 214 | end 215 | end # class StyleDecoration 216 | 217 | class FormDecoration < WrapperDecoration 218 | 219 | def global?() true end 220 | 221 | def render(r) 222 | r.form { render_inner(r) } 223 | end 224 | 225 | end # class FormDecoration 226 | 227 | class PageDecoration < WrapperDecoration 228 | 229 | def initialize(title='', stylesheets=[], javascripts=[]) 230 | @title = title 231 | @stylesheets = stylesheets 232 | @javascripts = javascripts 233 | super() 234 | end 235 | 236 | def global?() true end 237 | 238 | def render(r) 239 | r.page.title(@title).head { 240 | @stylesheets.each {|s| r.link_css(s) } 241 | @javascripts.each {|j| r.javascript.src(j) } 242 | r.style.type('text/css').with { r.define_divert(:styles) } 243 | r.javascript.with { r.define_divert(:javascripts) } 244 | }.with { 245 | render_inner(r) 246 | } 247 | end 248 | 249 | end # class PageDecoration 250 | 251 | end # module Wee 252 | -------------------------------------------------------------------------------- /lib/wee/dialog.rb: -------------------------------------------------------------------------------- 1 | require 'wee/component' 2 | 3 | module Wee 4 | class Dialog < Component; end 5 | 6 | # 7 | # Abstract class 8 | # 9 | class FormDialog < Dialog 10 | def initialize(caption) 11 | @caption = caption 12 | end 13 | 14 | def render(r) 15 | r.div.css_class('wee').with { 16 | render_caption(r) 17 | render_form(r) 18 | } 19 | end 20 | 21 | def render_caption(r) 22 | r.h3 @caption if @caption 23 | end 24 | 25 | def render_form(r) 26 | r.form.with { 27 | render_body(r) 28 | render_buttons(r) 29 | } 30 | end 31 | 32 | def render_body(r) 33 | end 34 | 35 | def render_buttons(r) 36 | return if buttons.empty? 37 | r.div.css_class('dialog-buttons').with { 38 | buttons.each do |title, return_value, sym, method| 39 | sym ||= title.downcase 40 | r.span.css_class("dialog-button-#{sym}").with { 41 | if method 42 | r.submit_button.callback_method(method).value(title) 43 | else 44 | r.submit_button.callback_method(:answer, return_value).value(title) 45 | end 46 | } 47 | end 48 | } 49 | end 50 | 51 | def buttons 52 | [] 53 | end 54 | end # class FormDialog 55 | 56 | class MessageDialog < FormDialog 57 | def initialize(caption, *buttons) 58 | super(caption) 59 | @buttons = buttons 60 | end 61 | 62 | def buttons 63 | @buttons 64 | end 65 | end 66 | 67 | class InformDialog < FormDialog 68 | def buttons 69 | [['Ok', nil, :ok]] 70 | end 71 | end # class InformDialog 72 | 73 | class ConfirmDialog < FormDialog 74 | def buttons 75 | [['Yes', true, :yes], ['No', false, :no]] 76 | end 77 | end # class ConfirmDialog 78 | 79 | class SingleSelectionDialog < FormDialog 80 | attr_accessor :selected_item 81 | 82 | def initialize(items, caption=nil, selected_item=nil) 83 | super(caption) 84 | @items = items 85 | @selected_item = selected_item 86 | end 87 | 88 | def state(s) super 89 | s.add_ivar(self, :@selected_item) 90 | end 91 | 92 | def render_body(r) 93 | r.select_list(@items).selected(@selected_item).callback_method(:selected_item=) 94 | end 95 | 96 | def buttons 97 | [['Ok', nil, :ok, :ok], ['Cancel', nil, :cancel, :cancel]] 98 | end 99 | 100 | def ok 101 | answer @selected_item 102 | end 103 | 104 | def cancel 105 | answer nil 106 | end 107 | end # class SingleSelectionDialog 108 | 109 | class TextInputDialog < Wee::FormDialog 110 | attr_accessor :text 111 | 112 | def initialize(caption=nil, text="", size=50) 113 | super(caption) 114 | @text = text 115 | @size = size 116 | end 117 | 118 | def state(s) super 119 | s.add_ivar(self, :@text) 120 | end 121 | 122 | def render_body(r) 123 | r.text_input.size(@size).callback_method(:set_text).value(@text || "") 124 | end 125 | 126 | def set_text(text) 127 | @text = text.strip 128 | end 129 | 130 | def buttons 131 | [['Ok', nil, :ok, :ok], ['Cancel', nil, :cancel, :cancel]] 132 | end 133 | 134 | def ok 135 | answer @text 136 | end 137 | 138 | def cancel 139 | answer nil 140 | end 141 | end # class TextInputDialog 142 | 143 | class TextAreaDialog < TextInputDialog 144 | def initialize(caption=nil, text="", cols=50, rows=5) 145 | super(caption, text, cols) 146 | @rows = rows 147 | end 148 | 149 | def render_body(r) 150 | r.text_area.cols(@size).rows(@rows).callback_method(:set_text).with(@text || "") 151 | end 152 | end # class TextAreaDialog 153 | 154 | # 155 | # Extend class Component with shortcuts for the dialogs above 156 | # 157 | class Component 158 | def confirm(question, &block) 159 | call! ConfirmDialog.new(question), &block 160 | end 161 | 162 | def inform(message, &block) 163 | call! InformDialog.new(message), &block 164 | end 165 | 166 | def choose_from(items, caption=nil, selected_item=nil, &block) 167 | call! SingleSelectionDialog.new(items, caption, selected_item), &block 168 | end 169 | end # class Component 170 | 171 | end # module Wee 172 | -------------------------------------------------------------------------------- /lib/wee/external_resource.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | class ExternalResource 4 | def initialize(mount_path=nil) 5 | @mount_path = mount_path || "/" + self.class.name.to_s.downcase.gsub("::", "_") 6 | end 7 | 8 | def install(builder) 9 | rd = resource_dir() 10 | builder.map(@mount_path) do 11 | run Rack::File.new(rd) 12 | end 13 | end 14 | 15 | def javascripts 16 | [] 17 | end 18 | 19 | def stylesheets 20 | [] 21 | end 22 | 23 | protected 24 | 25 | def resource_dir 26 | raise 27 | end 28 | 29 | def file_relative(_file, *subdirs) 30 | File.expand_path(File.join(File.dirname(_file), *subdirs)) 31 | end 32 | 33 | def mount_path_relative(*paths) 34 | paths.map {|path| "#{@mount_path}/#{path}"} 35 | end 36 | 37 | end # class ExternalResource 38 | 39 | end 40 | -------------------------------------------------------------------------------- /lib/wee/hello_world.rb: -------------------------------------------------------------------------------- 1 | require 'wee/root_component' 2 | 3 | class Wee::HelloWorld < Wee::RootComponent 4 | def render(r) 5 | r.text "Hello World from Wee!" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/wee/html_brushes.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | class Brush 4 | attr_accessor :canvas, :document 5 | 6 | # This method is called right after #initialize. It's only here to 7 | # simplify the implementation of Brushes, mainly to avoid passing all those 8 | # arguments to super. 9 | # 10 | # There is a bit of redundancy with canvas and document here. It's there to 11 | # avoid method calls. 12 | # 13 | # A brush is considered to be closed, when @document is nil. 14 | # 15 | def setup(canvas, document) 16 | @canvas = canvas 17 | @document = document 18 | end 19 | 20 | def with(*args, &block) 21 | @canvas.nest(&block) if block 22 | @document = @canvas = nil 23 | end 24 | 25 | def close 26 | with if @document 27 | end 28 | 29 | def self.nesting?() true end 30 | end 31 | 32 | class Brush::GenericTextBrush < Brush 33 | def with(text) 34 | @document.text(text) 35 | @document = @canvas = nil 36 | end 37 | 38 | def self.nesting?() false end 39 | end 40 | 41 | class Brush::GenericEncodedTextBrush < Brush::GenericTextBrush 42 | def with(text) 43 | @document.encode_text(text) 44 | @document = @canvas = nil 45 | end 46 | end 47 | 48 | class Brush::GenericTagBrush < Brush 49 | def self.html_attr(attr, hash={}) 50 | name = hash[:html_name] || attr 51 | if hash[:type] == :bool 52 | class_eval %{ 53 | def #{ attr }(bool=true) 54 | if bool 55 | @attributes[:"#{ name }"] = nil 56 | else 57 | @attributes.delete(:"#{ name }") 58 | end 59 | self 60 | end 61 | } 62 | else 63 | class_eval %{ 64 | def #{ attr }(value) 65 | if value == nil 66 | @attributes.delete(:"#{ name }") 67 | else 68 | @attributes[:"#{ name }"] = value 69 | end 70 | self 71 | end 72 | } 73 | end 74 | 75 | (hash[:aliases] || []).each do |a| 76 | class_eval "alias #{ a } #{ attr }" 77 | end 78 | 79 | (hash[:shortcuts] || {}).each_pair do |k, v| 80 | class_eval "def #{ k }() #{ attr }(#{ v.inspect }) end" 81 | end 82 | end 83 | end 84 | 85 | class Brush::GenericTagBrush < Brush 86 | html_attr :id 87 | html_attr :name # XXX 88 | html_attr :css_class, :html_name => :class 89 | html_attr :css_style, :html_name => :style, :aliases => [:style] 90 | html_attr :onclick 91 | html_attr :ondblclick 92 | 93 | def initialize(tag) 94 | super() 95 | @tag = tag 96 | @attributes = Hash.new 97 | end 98 | 99 | # 100 | # Assigns a unique DOM id 101 | # 102 | def oid 103 | id(get_oid()) 104 | end 105 | 106 | # 107 | # Returns a unique DOM id for the underlying component 108 | # 109 | def get_oid 110 | "wee_#{@canvas.current_component.object_id}" 111 | end 112 | 113 | # 114 | # generic support for onXXX events 115 | # 116 | 117 | EVENTS = {:click => 'onclick'.freeze, 118 | :dblclick => 'ondblclick'.freeze, 119 | :mouseover => 'onmouseover'.freeze, 120 | :mouseout => 'onmouseout'.freeze, 121 | :change => 'onchange'.freeze}.freeze 122 | 123 | def javascript_on(event, javascript) 124 | ev = EVENTS[event] 125 | raise ArgumentError unless ev 126 | @attributes[ev] = "javascript: #{javascript};" 127 | self 128 | end 129 | 130 | def callback_on(event, &block) 131 | raise ArgumentError unless block 132 | url = @canvas.url_for_callback(block) 133 | javascript_on(event, "document.location.href='#{ url }'") 134 | self 135 | end 136 | 137 | def update_on(event, &render_block) 138 | raise ArgumentError unless render_block 139 | url = @canvas.url_for_callback(@canvas.session.render_ajax_proc(render_block, @canvas.current_component)) 140 | javascript_on(event, "wee.update('#{ url }')") 141 | self 142 | end 143 | 144 | def update_component_on(event, component=nil, &callback_block) 145 | component ||= @canvas.current_component 146 | 147 | render_block = proc {|r| 148 | callback_block.call if callback_block 149 | r.render(component) 150 | } 151 | 152 | url = @canvas.url_for_callback(@canvas.session.render_ajax_proc(render_block, component)) 153 | javascript_on(event, "wee.update('#{ url }')") 154 | self 155 | end 156 | 157 | def onclick_javascript(v) 158 | javascript_on(:click, v) 159 | end 160 | 161 | def onclick_callback(&block) 162 | callback_on(:click, &block) 163 | end 164 | 165 | def ondblclick_callback(&block) 166 | callback_on(:dblclick, &block) 167 | end 168 | 169 | def with(text=nil, &block) 170 | @document.start_tag(@tag, @attributes) 171 | @document.text(text) if text 172 | @canvas.nest(&block) if block 173 | @document.end_tag(@tag) 174 | @document = @canvas = nil 175 | end 176 | 177 | end 178 | 179 | class Brush::GenericSingleTagBrush < Brush::GenericTagBrush 180 | def with 181 | @document.single_tag(@tag, @attributes) 182 | @document = @canvas = nil 183 | end 184 | 185 | def self.nesting?() false end 186 | end 187 | 188 | class Brush::ImageTag < Brush::GenericSingleTagBrush 189 | HTML_TAG = 'img'.freeze 190 | 191 | html_attr :src 192 | html_attr :width 193 | html_attr :height 194 | html_attr :border 195 | html_attr :alt 196 | 197 | def initialize 198 | super(HTML_TAG) 199 | end 200 | end 201 | 202 | class Brush::JavascriptTag < Brush::GenericTagBrush 203 | HTML_TAG = 'script'.freeze 204 | HTML_TYPE = 'text/javascript'.freeze 205 | 206 | html_attr :src 207 | html_attr :type 208 | 209 | def initialize 210 | super(HTML_TAG) 211 | type(HTML_TYPE) 212 | end 213 | end 214 | 215 | class Brush::StyleTag < Brush::GenericTagBrush 216 | HTML_TAG = 'style'.freeze 217 | 218 | html_attr :type 219 | 220 | def initialize 221 | super(HTML_TAG) 222 | end 223 | 224 | def with(text=nil, &block) 225 | @document.start_tag(@tag, @attributes) 226 | @document.write("\n") 230 | @document.end_tag(@tag) 231 | @document = @canvas = nil 232 | end 233 | end 234 | 235 | #--------------------------------------------------------------------- 236 | # Table 237 | #--------------------------------------------------------------------- 238 | 239 | class Brush::TableTag < Brush::GenericTagBrush 240 | HTML_TAG = 'table'.freeze 241 | 242 | html_attr :cellspacing 243 | html_attr :border 244 | 245 | def initialize 246 | super(HTML_TAG) 247 | end 248 | end 249 | 250 | class Brush::TableRowTag < Brush::GenericTagBrush 251 | HTML_TAG = 'tr'.freeze 252 | 253 | html_attr :align, :shortcuts => { 254 | :align_top => :top, :align_bottom => :bottom 255 | } 256 | 257 | def initialize 258 | super(HTML_TAG) 259 | end 260 | 261 | def columns(*cols, &block) 262 | with { 263 | cols.each {|col| 264 | @canvas.table_data.with { 265 | if block 266 | block.call(col) 267 | else 268 | @canvas.text(col) 269 | end 270 | } 271 | } 272 | } 273 | end 274 | 275 | def headings(*headers, &block) 276 | with { 277 | headers.each {|header| 278 | @canvas.table_header.with { 279 | if block 280 | block.call(header) 281 | else 282 | @canvas.text(header) 283 | end 284 | } 285 | } 286 | } 287 | end 288 | 289 | def spanning_column(str, colspan) 290 | with { @canvas.table_data.col_span(colspan).with(str) } 291 | end 292 | 293 | def spacer 294 | with { @canvas.table_data { @canvas.space } } 295 | end 296 | end 297 | 298 | class Brush::TableDataTag < Brush::GenericTagBrush 299 | HTML_TAG = 'td'.freeze 300 | 301 | html_attr :colspan 302 | html_attr :align, :shortcuts => { 303 | :align_top => :top, 304 | :align_bottom => :bottom 305 | } 306 | 307 | def initialize 308 | super(HTML_TAG) 309 | end 310 | end 311 | 312 | class Brush::TableHeaderTag < Brush::GenericTagBrush 313 | HTML_TAG = 'th'.freeze 314 | 315 | html_attr :colspan 316 | html_attr :align, :shortcuts => { 317 | :align_top => :top, 318 | :align_bottom => :bottom 319 | } 320 | 321 | def initialize 322 | super(HTML_TAG) 323 | end 324 | end 325 | 326 | #--------------------------------------------------------------------- 327 | # Callback Mixin 328 | #--------------------------------------------------------------------- 329 | 330 | module CallbackMixin 331 | 332 | def callback_method(id, *args) 333 | @callback = self 334 | @callback_object = @canvas.current_component 335 | @callback_id = id 336 | @callback_args = args 337 | __callback() 338 | return self 339 | end 340 | 341 | def callback(&block) 342 | @callback = block 343 | __callback() 344 | return self 345 | end 346 | 347 | # 348 | # Is called when #callback_method was used. 349 | # 350 | def call(*args) 351 | args.push(*@callback_args) 352 | @callback_object.send(@callback_id, *args) 353 | end 354 | 355 | end 356 | 357 | #--------------------------------------------------------------------- 358 | # Form 359 | #--------------------------------------------------------------------- 360 | 361 | class Brush::FormTag < Brush::GenericTagBrush 362 | HTML_TAG = 'form'.freeze 363 | HTML_METHOD_POST = 'POST'.freeze 364 | 365 | html_attr :action 366 | html_attr :enctype 367 | 368 | # 369 | # Use this enctype when you have a FileUploadTag field. 370 | # 371 | def enctype_multipart 372 | enctype('multipart/form-data') 373 | end 374 | 375 | def initialize 376 | super(HTML_TAG) 377 | @attributes[:method] = HTML_METHOD_POST 378 | end 379 | 380 | def with(&block) 381 | # If no action was specified, use a dummy one. 382 | unless @attributes.has_key?(:action) 383 | @attributes[:action] = @canvas.build_url 384 | end 385 | super 386 | end 387 | 388 | include CallbackMixin 389 | 390 | def __callback; action(@canvas.url_for_callback(@callback)) end 391 | 392 | =begin 393 | def onsubmit_update(update_id, &block) 394 | raise ArgumentError if symbol and block 395 | url = @canvas.url_for_callback(block, :live_update) 396 | onsubmit("javascript: new Ajax.Updater('#{ update_id }', '#{ url }', {method:'get', parameters: Form.serialize(this)}); return false;") 397 | end 398 | =end 399 | end 400 | 401 | #--------------------------------------------------------------------- 402 | # Form - Input 403 | #--------------------------------------------------------------------- 404 | 405 | class Brush::InputTag < Brush::GenericSingleTagBrush 406 | HTML_TAG = 'input'.freeze 407 | 408 | html_attr :type 409 | html_attr :name 410 | html_attr :value 411 | html_attr :size 412 | html_attr :maxlength 413 | html_attr :src 414 | html_attr :checked, :type => :bool 415 | html_attr :disabled, :type => :bool 416 | html_attr :readonly, :type => :bool 417 | 418 | def initialize(_type) 419 | super(HTML_TAG) 420 | type(_type) 421 | end 422 | 423 | include CallbackMixin 424 | 425 | def __callback; name(@canvas.register_callback(:input, @callback)) end 426 | end 427 | 428 | class Brush::TextInputTag < Brush::InputTag 429 | HTML_TYPE = 'text'.freeze 430 | 431 | def initialize 432 | super(HTML_TYPE) 433 | end 434 | end 435 | 436 | class Brush::HiddenInputTag < Brush::InputTag 437 | HTML_TYPE = 'hidden'.freeze 438 | 439 | def initialize 440 | super(HTML_TYPE) 441 | end 442 | end 443 | 444 | class Brush::PasswordInputTag < Brush::InputTag 445 | HTML_TYPE = 'password'.freeze 446 | 447 | def initialize 448 | super(HTML_TYPE) 449 | end 450 | end 451 | 452 | class Brush::CheckboxTag < Brush::InputTag 453 | HTML_TYPE = 'checkbox'.freeze 454 | 455 | def initialize 456 | super(HTML_TYPE) 457 | end 458 | 459 | def __callback; end # do nothing 460 | 461 | def with 462 | if @callback 463 | n = @canvas.register_callback(:input, proc {|input| 464 | @callback.call(input.send(input.kind_of?(Array) ? :include? : :==, '1')) 465 | }) 466 | @document.single_tag('input', :type => 'hidden', :name => n, :value => '0') 467 | name(n) 468 | value('1') 469 | end 470 | super 471 | end 472 | end 473 | 474 | # 475 | # Use a
tag with enctype_multipart! 476 | # 477 | class Brush::FileUploadTag < Brush::InputTag 478 | HTML_TYPE = 'file'.freeze 479 | 480 | def initialize 481 | super(HTML_TYPE) 482 | end 483 | end 484 | 485 | #--------------------------------------------------------------------- 486 | # Form - Buttons 487 | #--------------------------------------------------------------------- 488 | 489 | class Brush::ActionInputTag < Brush::InputTag 490 | include CallbackMixin 491 | 492 | def __callback; name(@canvas.register_callback(:action, @callback)) end 493 | end 494 | 495 | class Brush::SubmitButtonTag < Brush::ActionInputTag 496 | HTML_TYPE = 'submit'.freeze 497 | 498 | def initialize 499 | super(HTML_TYPE) 500 | end 501 | end 502 | 503 | # 504 | # NOTE: The form-fields returned by a image-button-tag is browser-specific. 505 | # Most browsers do not send the "name" key together with the value specified 506 | # by "value", only "name.x" and "name.y". This conforms to the standard. But 507 | # Firefox also sends "name"="value". This is why I raise an exception from 508 | # the #value method. Note that it's neccessary to parse the passed 509 | # form-fields and generate a "name" fields in the request, to make this 510 | # image-button work. 511 | # 512 | class Brush::ImageButtonTag < Brush::ActionInputTag 513 | HTML_TYPE = 'image'.freeze 514 | 515 | def initialize 516 | super(HTML_TYPE) 517 | end 518 | 519 | undef :value 520 | end 521 | 522 | #--------------------------------------------------------------------- 523 | # Form - Textarea 524 | #--------------------------------------------------------------------- 525 | 526 | class Brush::TextAreaTag < Brush::GenericTagBrush 527 | HTML_TAG = 'textarea'.freeze 528 | 529 | html_attr :name 530 | html_attr :rows 531 | html_attr :cols 532 | html_attr :tabindex 533 | html_attr :accesskey 534 | html_attr :onfocus 535 | html_attr :onblur 536 | html_attr :onselect 537 | html_attr :onchange 538 | html_attr :disabled, :type => :bool 539 | html_attr :readonly, :type => :bool 540 | 541 | def initialize 542 | super(HTML_TAG) 543 | end 544 | 545 | def value(val) 546 | @value = val 547 | self 548 | end 549 | 550 | def with(value=nil) 551 | super(value || @value) 552 | end 553 | 554 | include CallbackMixin 555 | 556 | def __callback; name(@canvas.register_callback(:input, @callback)) end 557 | end 558 | 559 | #--------------------------------------------------------------------- 560 | # Form - Select 561 | #--------------------------------------------------------------------- 562 | 563 | class Brush::SelectListTag < Brush::GenericTagBrush 564 | HTML_TAG = 'select'.freeze 565 | 566 | html_attr :size 567 | html_attr :disabled, :type => :bool 568 | html_attr :readonly, :type => :bool 569 | html_attr :multiple, :type => :bool, :aliases => [:multi] 570 | 571 | def initialize(items) 572 | super(HTML_TAG) 573 | @items = items 574 | end 575 | 576 | def items(items) 577 | @items = items 578 | self 579 | end 580 | 581 | def selected(arg=nil, &block) 582 | raise ArgumentError if arg and block 583 | @selected = block || arg 584 | self 585 | end 586 | 587 | def labels(arg=nil, &block) 588 | raise ArgumentError if arg and block 589 | if block 590 | @labels = proc {|i| block.call(@items[i])} 591 | else 592 | @labels = arg 593 | end 594 | self 595 | end 596 | 597 | include CallbackMixin 598 | 599 | def __callback 600 | # 601 | # A callback was specified. We have to wrap it inside another 602 | # callback, as we want to perform some additional actions. 603 | # 604 | name(@canvas.register_callback(:input, method(:handler)) + "[]") 605 | end 606 | 607 | def handler(input) 608 | choosen = input.map {|idx| 609 | idx = Integer(idx) 610 | raise IndexError if idx < 0 or idx > @items.size 611 | @items[idx] 612 | } 613 | 614 | if @attributes.has_key?(:multiple) 615 | @callback.call(choosen) 616 | elsif choosen.size > 1 617 | raise "more than one element was choosen from a not-multiple SelectListTag" 618 | else 619 | @callback.call(choosen.first) 620 | end 621 | end 622 | 623 | protected :handler 624 | 625 | def with 626 | @labels ||= @items.collect {|i| i.to_s} 627 | 628 | if @attributes.has_key?(:multiple) 629 | @selected ||= Array.new 630 | meth = @selected.kind_of?(Proc) ? (:call) : (:include?) 631 | else 632 | meth = @selected.kind_of?(Proc) ? (:call) : (:==) 633 | end 634 | 635 | super { 636 | @items.each_index do |i| 637 | @canvas.option.value(i).selected(@selected.send(meth, @items[i])).with(@labels[i]) 638 | end 639 | } 640 | end 641 | end 642 | 643 | class Brush::SelectOptionTag < Brush::GenericTagBrush 644 | HTML_TAG = 'option'.freeze 645 | 646 | html_attr :value 647 | html_attr :selected, :type => :bool 648 | 649 | def initialize 650 | super(HTML_TAG) 651 | end 652 | end 653 | 654 | #--------------------------------------------------------------------- 655 | # Form - Radio 656 | #--------------------------------------------------------------------- 657 | 658 | class Brush::RadioGroup 659 | def initialize(canvas) 660 | @name = canvas.register_callback(:input, self) 661 | @callbacks = {} 662 | @ids = Wee::IdGenerator::Sequential.new 663 | end 664 | 665 | def add_callback(callback) 666 | value = @ids.next.to_s 667 | @callbacks[value] = callback 668 | return [@name, value] 669 | end 670 | 671 | def call(value) 672 | if @callbacks.has_key?(value) 673 | cb = @callbacks[value] 674 | cb.call(value) if cb 675 | else 676 | raise "invalid radio button/group value" 677 | end 678 | end 679 | end 680 | 681 | class Brush::RadioButtonTag < Brush::InputTag 682 | HTML_TYPE = 'radio'.freeze 683 | 684 | def initialize 685 | super(HTML_TYPE) 686 | end 687 | 688 | def group(radio_group) 689 | @group = radio_group 690 | self 691 | end 692 | 693 | include CallbackMixin 694 | 695 | def __callback; end # do nothing 696 | 697 | def with 698 | if @group 699 | n, v = @group.add_callback(@callback) 700 | name(n) 701 | value(v) 702 | end 703 | super 704 | end 705 | end 706 | 707 | #--------------------------------------------------------------------- 708 | # Misc 709 | #--------------------------------------------------------------------- 710 | 711 | class Brush::LinkTag < Brush::GenericTagBrush 712 | HTML_TAG = 'link'.freeze 713 | 714 | html_attr :href, :aliases => [:url] 715 | html_attr :type 716 | html_attr :rel 717 | 718 | def initialize 719 | super(HTML_TAG) 720 | end 721 | end 722 | 723 | class Brush::AnchorTag < Brush::GenericTagBrush 724 | HTML_TAG = 'a'.freeze 725 | 726 | html_attr :href, :aliases => [:url] 727 | html_attr :title, :aliases => [:tooltip] 728 | 729 | def initialize 730 | super(HTML_TAG) 731 | href('#') 732 | end 733 | 734 | def info(info=nil) 735 | @info = info 736 | self 737 | end 738 | 739 | include CallbackMixin 740 | 741 | def __callback 742 | url(@canvas.url_for_callback(@callback, :action, @info ? {:info => @info} : {})) 743 | end 744 | end 745 | 746 | class Brush::Page < Brush 747 | HTML_HTML = 'html'.freeze 748 | HTML_HEAD = 'head'.freeze 749 | HTML_TITLE = 'title'.freeze 750 | HTML_BODY = 'body'.freeze 751 | 752 | def with(text=nil, &block) 753 | @document.start_tag(HTML_HTML) 754 | @document.start_tag(HTML_HEAD) 755 | 756 | if @title 757 | @document.start_tag(HTML_TITLE) 758 | @document.text(@title) 759 | @document.end_tag(HTML_TITLE) 760 | end 761 | 762 | if @head 763 | @canvas.nest(&@head) 764 | end 765 | 766 | @document.end_tag(HTML_HEAD) 767 | @document.start_tag(HTML_BODY) 768 | 769 | if text 770 | raise ArgumentError if block 771 | @document.text(text) 772 | else 773 | @canvas.nest(&block) if block 774 | end 775 | 776 | @document.end_tag(HTML_BODY) 777 | @document.end_tag(HTML_HTML) 778 | 779 | @document = @canvas = nil 780 | end 781 | 782 | def title(t) 783 | @title = t 784 | self 785 | end 786 | 787 | def head(&block) 788 | raise ArgumentError unless block 789 | @head = block 790 | self 791 | end 792 | 793 | end 794 | 795 | end # module Wee 796 | -------------------------------------------------------------------------------- /lib/wee/html_canvas.rb: -------------------------------------------------------------------------------- 1 | require 'wee/renderer' 2 | 3 | module Wee 4 | 5 | class HtmlCanvas < Renderer 6 | 7 | def initialize(*args) 8 | super 9 | @current_brush = nil 10 | end 11 | 12 | def close 13 | @current_brush.close if @current_brush 14 | @current_brush = nil 15 | end 16 | 17 | def nest 18 | old_brush = @current_brush 19 | # we don't want that Brush#close is calledas #nest 20 | # is called from #with -> this avoids an infinite loop 21 | @current_brush = nil 22 | yield 23 | @current_brush.close if @current_brush 24 | @current_brush = old_brush 25 | end 26 | 27 | def self.brush_tag(attr, klass, *args_to_new) 28 | args_to_new = args_to_new.map {|a| a.inspect}.join(", ") 29 | if klass.instance_method(:with).arity != 0 30 | class_eval %{ 31 | def #{attr}(*args, &block) 32 | handle(#{klass}.new(#{args_to_new}), *args, &block) 33 | end 34 | } 35 | elsif klass.nesting? 36 | class_eval %{ 37 | def #{attr}(&block) 38 | handle2(#{klass}.new(#{args_to_new}), &block) 39 | end 40 | } 41 | else 42 | class_eval %{ 43 | def #{attr} 44 | handle3(#{klass}.new(#{args_to_new})) 45 | end 46 | } 47 | end 48 | end 49 | 50 | def self.generic_tag(*attrs) 51 | attrs.each {|attr| brush_tag attr, Brush::GenericTagBrush, attr } 52 | end 53 | 54 | def self.generic_single_tag(*attrs) 55 | attrs.each {|attr| brush_tag attr, Brush::GenericSingleTagBrush, attr } 56 | end 57 | 58 | generic_tag :html, :head, :body, :title, :label 59 | generic_tag :h1, :h2, :h3, :h4, :h5 60 | generic_tag :div, :span, :ul, :ol, :li, :pre 61 | generic_single_tag :hr 62 | 63 | brush_tag :link, Brush::LinkTag 64 | brush_tag :table, Brush::TableTag 65 | brush_tag :table_row, Brush::TableRowTag 66 | brush_tag :table_data, Brush::TableDataTag 67 | brush_tag :table_header, Brush::TableHeaderTag 68 | brush_tag :form, Brush::FormTag 69 | brush_tag :input, Brush::InputTag 70 | brush_tag :hidden_input, Brush::HiddenInputTag 71 | brush_tag :password_input, Brush::PasswordInputTag 72 | brush_tag :text_input, Brush::TextInputTag 73 | brush_tag :radio_button, Brush::RadioButtonTag 74 | brush_tag :check_box, Brush::CheckboxTag; alias checkbox check_box 75 | brush_tag :text_area, Brush::TextAreaTag 76 | brush_tag :option, Brush::SelectOptionTag 77 | brush_tag :submit_button, Brush::SubmitButtonTag 78 | brush_tag :image_button, Brush::ImageButtonTag 79 | brush_tag :file_upload, Brush::FileUploadTag 80 | brush_tag :page, Brush::Page 81 | brush_tag :anchor, Brush::AnchorTag 82 | brush_tag :javascript, Brush::JavascriptTag 83 | brush_tag :image, Brush::ImageTag 84 | brush_tag :style, Brush::StyleTag 85 | 86 | brush_tag :bold, Brush::GenericTagBrush, :b 87 | brush_tag :paragraph, Brush::GenericTagBrush, :p 88 | brush_tag :break, Brush::GenericSingleTagBrush, :br 89 | 90 | def select_list(items, &block) 91 | handle2(Brush::SelectListTag.new(items), &block) 92 | end 93 | 94 | HTML_NBSP = " ".freeze 95 | 96 | def space(n=1) 97 | text(HTML_NBSP*n) 98 | end 99 | 100 | def text(str) 101 | @current_brush.close if @current_brush 102 | @current_brush = nil 103 | @document.text(str) 104 | nil 105 | end 106 | 107 | alias << text 108 | 109 | def encode_text(str) 110 | @current_brush.close if @current_brush 111 | @current_brush = nil 112 | @document.encode_text(str) 113 | nil 114 | end 115 | 116 | def css(str) 117 | style.type('text/css').with(str) 118 | end 119 | 120 | # 121 | # Depends on an existing divert location :styles. 122 | # 123 | def render_style(component) 124 | once(component.class) { try_divert(:styles, component.style) } 125 | end 126 | 127 | # 128 | # converts \n into
129 | # 130 | def multiline_text(str, encode=true) 131 | @current_brush.close if @current_brush 132 | @current_brush = nil 133 | 134 | first = true 135 | str.each_line do |line| 136 | @document.single_tag(:br) unless first 137 | first = false 138 | 139 | if encode 140 | @document.encode_text(line) 141 | else 142 | @document.text(line) 143 | end 144 | end 145 | end 146 | 147 | # 148 | # Define a divert location 149 | # 150 | def define_divert(tag) 151 | @document.define_divert(tag) 152 | end 153 | 154 | # 155 | # Change into an existing divert location and 156 | # append +txt+ or the contents of +block+. 157 | # 158 | def divert(tag, txt=nil, &block) 159 | @document.divert(tag, txt, &block) 160 | end 161 | 162 | # 163 | # If the divert +tag+ exists, divert, otherwise 164 | # do nothing. 165 | # 166 | def try_divert(tag, txt=nil, &block) 167 | if @document.has_divert?(tag) 168 | divert(tag, txt, &block) 169 | true 170 | else 171 | false 172 | end 173 | end 174 | 175 | # 176 | # Render specific markup only once. For example style and/or 177 | # javascript of a component which has many instances. 178 | # 179 | def once(tag) 180 | return if @document.set.has_key?(tag) 181 | @document.set[tag] = true 182 | yield if block_given? 183 | end 184 | 185 | HTML_TYPE_CSS = 'text/css'.freeze 186 | HTML_REL_STYLESHEET = 'stylesheet'.freeze 187 | 188 | def link_css(url) 189 | link.type(HTML_TYPE_CSS).rel(HTML_REL_STYLESHEET).href(url) 190 | end 191 | 192 | def new_radio_group 193 | Wee::Brush::RadioButtonTag::RadioGroup.new(self) 194 | end 195 | 196 | def url_for_callback(callback, type=:action, hash=nil) 197 | url_for_callback_id(register_callback(type, callback), hash) 198 | end 199 | 200 | def url_for_callback_id(callback_id, hash=nil) 201 | if hash 202 | build_url(hash.update(:callback_id => callback_id)) 203 | else 204 | build_url(:callback_id => callback_id) 205 | end 206 | end 207 | 208 | def build_url(*args) 209 | @request.build_url(*args) 210 | end 211 | 212 | def register_callback(type, callback) 213 | cbs = @callbacks 214 | if cbs.respond_to?("#{type}_callbacks") 215 | cbs.send("#{type}_callbacks").register(@current_component, callback) 216 | else 217 | raise 218 | end 219 | end 220 | 221 | protected 222 | 223 | def set_brush(brush) 224 | brush.setup(self, @document) 225 | 226 | @current_brush.close if @current_brush 227 | @current_brush = brush 228 | 229 | return brush 230 | end 231 | 232 | def handle(brush, *args, &block) 233 | if block or not args.empty? 234 | set_brush(brush) 235 | brush.with(*args, &block) 236 | else 237 | set_brush(brush) 238 | end 239 | end 240 | 241 | def handle2(brush, &block) 242 | if block 243 | set_brush(brush) 244 | brush.with(&block) 245 | else 246 | set_brush(brush) 247 | end 248 | end 249 | 250 | alias handle3 set_brush 251 | 252 | end # class HtmlCanvas 253 | 254 | end # module Wee 255 | -------------------------------------------------------------------------------- /lib/wee/html_document.rb: -------------------------------------------------------------------------------- 1 | require 'wee/html_writer' 2 | 3 | module Wee 4 | 5 | # 6 | # Represents a complete HTML document. 7 | # 8 | class HtmlDocument < HtmlWriter 9 | def initialize 10 | super([]) 11 | end 12 | 13 | def set 14 | @set ||= {} 15 | end 16 | 17 | def has_divert?(tag) 18 | @divert and @divert[tag] 19 | end 20 | 21 | def define_divert(tag) 22 | raise ArgumentError if has_divert?(tag) 23 | @divert ||= {} 24 | @port << (@divert[tag] = []) 25 | end 26 | 27 | def divert(tag, txt=nil, &block) 28 | raise ArgumentError unless has_divert?(tag) 29 | raise ArgumentError if txt and block 30 | 31 | divert = @divert[tag] 32 | 33 | if txt 34 | divert << txt 35 | end 36 | 37 | if block 38 | old_port = @port 39 | begin 40 | @port = divert 41 | block.call 42 | ensure 43 | @port = old_port 44 | end 45 | end 46 | end 47 | 48 | def to_s 49 | @port.join 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/wee/html_writer.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Wee 4 | 5 | # 6 | # A class used to write out HTML documents easily. 7 | # 8 | # Usage: 9 | # 10 | # w = Wee::HtmlWriter.new(doc='') 11 | # w.start_tag('html') 12 | # w.start_tag('body') 13 | # w.start_tag('a', 'href' => 'http://...') 14 | # w.text('link') 15 | # w.end_tag('a') 16 | # w.end_tag('body') 17 | # w.end_tag('html') 18 | # 19 | # p doc # => 'link' 20 | # 21 | class HtmlWriter 22 | 23 | attr_accessor :port 24 | 25 | def initialize(port=[]) 26 | @port = port 27 | end 28 | 29 | CLOSING = ">".freeze 30 | SINGLE_CLOSING = " />".freeze 31 | 32 | def start_tag(tag, attributes=nil, single=false) 33 | if attributes 34 | @port << "<#{tag}" 35 | attributes.each {|k, v| 36 | if v 37 | @port << %[ #{ k }="#{ v }"] 38 | else 39 | @port << %[ #{ k }] 40 | end 41 | } 42 | @port << (single ? SINGLE_CLOSING : CLOSING) 43 | else 44 | @port << (single ? "<#{tag} />" : "<#{tag}>") 45 | end 46 | end 47 | 48 | def single_tag(tag, attributes=nil) 49 | start_tag(tag, attributes, true) 50 | end 51 | 52 | def end_tag(tag) 53 | @port << "" 54 | end 55 | 56 | def text(str) 57 | @port << str.to_s 58 | end 59 | 60 | def encode_text(str) 61 | @port << Rack::Utils.escape_html(str.to_s) 62 | end 63 | 64 | def write(str) 65 | @port << str 66 | end 67 | alias << write 68 | 69 | end # class HtmlWriter 70 | 71 | end # module Wee 72 | -------------------------------------------------------------------------------- /lib/wee/id_generator.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | # 4 | # Abstract base class of all id generators. 5 | # 6 | class IdGenerator 7 | def next 8 | raise "subclass responsibility" 9 | end 10 | end 11 | 12 | # 13 | # Sequential id generator. 14 | # 15 | # Returned ids are guaranteed to be unique, but they are easily guessable. 16 | # 17 | class IdGenerator::Sequential < IdGenerator 18 | def initialize(initial_value=0) 19 | @value = initial_value - 1 20 | end 21 | 22 | def next 23 | (@value += 1).to_s(36) 24 | end 25 | end 26 | 27 | # 28 | # Returned ids are unique with a very high probability and it's 29 | # very hard to guess the next or any used id. 30 | # 31 | class IdGenerator::Secure < IdGenerator 32 | 33 | require 'digest/md5' 34 | begin 35 | require 'securerandom' 36 | rescue LoadError 37 | end 38 | 39 | def initialize(salt='wee') 40 | @salt = salt 41 | 42 | @use_secure = false 43 | if defined?(::SecureRandom) 44 | begin 45 | @use_secure = true if next_secure != next_secure 46 | rescue NotImplementedError 47 | end 48 | end 49 | end 50 | 51 | def next 52 | pack(@use_secure ? next_secure : next_md5) 53 | end 54 | 55 | protected 56 | 57 | def next_md5 58 | now = Time.now 59 | dig = Digest::MD5.new 60 | dig.update(now.to_s) 61 | dig.update(now.usec.to_s) 62 | dig.update(rand(0).to_s) 63 | dig.update($$.to_s) 64 | dig.update(@salt.to_s) 65 | dig.digest 66 | end 67 | 68 | def next_secure 69 | SecureRandom.random_bytes(16) 70 | end 71 | 72 | def pack(str) 73 | packed = [str].pack('m') 74 | packed.tr!("=\r\n", '') 75 | packed.tr!('+/', '-_') 76 | packed 77 | end 78 | 79 | end 80 | 81 | end # module Wee 82 | -------------------------------------------------------------------------------- /lib/wee/jquery.rb: -------------------------------------------------------------------------------- 1 | require 'wee/external_resource' 2 | 3 | class Wee::JQuery < Wee::ExternalResource 4 | def javascripts 5 | mount_path_relative('jquery-1.3.2.min.js', 'wee-jquery.js') 6 | end 7 | 8 | def resource_dir 9 | file_relative(__FILE__, 'jquery') 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/wee/jquery/wee-jquery.js: -------------------------------------------------------------------------------- 1 | var wee = {}; 2 | 3 | wee._update_elements = function(_,e) { 4 | var src = jQuery(e); 5 | var id = src.attr('id'); 6 | if (id) 7 | jQuery('#'+id).replaceWith(src); 8 | else 9 | jQuery('html > body').append(src); 10 | }; 11 | 12 | wee._update_callback = function(data) { 13 | jQuery(data).each(wee._update_elements); 14 | }; 15 | 16 | wee.update = function(url) { 17 | jQuery.get(url, {}, wee._update_callback, 'html'); 18 | return false; 19 | }; 20 | -------------------------------------------------------------------------------- /lib/wee/locale.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Locale settings 3 | # 4 | 5 | require 'wee/application' 6 | require 'wee/session' 7 | require "fast_gettext" 8 | 9 | include FastGettext::Translation 10 | 11 | class Wee::Application 12 | class << self 13 | attr_accessor :text_domain 14 | attr_accessor :default_locale 15 | attr_accessor :available_locales 16 | end 17 | 18 | attr_writer :text_domain 19 | attr_writer :default_locale 20 | attr_writer :available_locales 21 | 22 | def text_domain 23 | @text_domain || self.class.text_domain 24 | end 25 | 26 | def default_locale 27 | @default_locale || self.class.default_locale 28 | end 29 | 30 | def available_locales 31 | @available_locales || self.class.available_locales 32 | end 33 | 34 | def self.load_locale(text_domain, available_locales, default_locale, params={}) 35 | FastGettext.add_text_domain(text_domain, params) 36 | @text_domain = text_domain 37 | @available_locales = available_locales 38 | @default_locale = default_locale 39 | end 40 | end 41 | 42 | class Wee::Session 43 | attr_writer :locale 44 | 45 | def locale 46 | @locale || application.default_locale 47 | end 48 | 49 | def awake 50 | if lc = self.locale 51 | FastGettext.text_domain = application.text_domain 52 | FastGettext.available_locales = application.available_locales 53 | FastGettext.locale = lc 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/wee/lru_cache.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | # 4 | # Implementation of a Least Recently Used (LRU) Cache 5 | # 6 | class LRUCache 7 | 8 | # 9 | # Common interface for all items 10 | # 11 | module Item 12 | attr_accessor :lru_time 13 | end 14 | 15 | def initialize(capacity=20) 16 | @capacity = capacity 17 | @store = Hash.new 18 | @time = 0 19 | end 20 | 21 | def has_key?(key) 22 | @store.has_key?(key) 23 | end 24 | 25 | def delete(key) 26 | @store.delete(key) 27 | end 28 | 29 | def delete_if(&block) 30 | @store.delete_if(&block) 31 | end 32 | 33 | def fetch(key, default_value=nil) 34 | if item = @store[key] 35 | touch(item) 36 | item 37 | else 38 | default_value 39 | end 40 | end 41 | 42 | def store(key, item) 43 | touch(item) 44 | compact() 45 | @store[key] = item 46 | end 47 | 48 | protected 49 | 50 | # 51 | # Is called whenever an item is looked up or stored to update it's 52 | # timestamp to maintain least recently used information. 53 | # 54 | def touch(item) 55 | item.lru_time = (@time += 1) 56 | end 57 | 58 | # 59 | # Is called for each item that is replaced from cache. Overwrite. 60 | # 61 | def purge(item) 62 | end 63 | 64 | # 65 | # Is called before replacing old items in order to remove items 66 | # known-to-be no longer in use. Overwrite. 67 | # 68 | def garbage_collect 69 | end 70 | 71 | # 72 | # Replaces old items and makes place for new. 73 | # 74 | def compact 75 | garbage_collect() if @store.size >= @capacity 76 | while @store.size >= @capacity 77 | purge(@store.delete(min_key()) || raise) 78 | end 79 | end 80 | 81 | # 82 | # Returns the key of the minimum item 83 | # 84 | def min_key 85 | min_k, _ = @store.min_by {|_, item| item.lru_time} 86 | return min_k 87 | end 88 | 89 | end # class LRUCache 90 | 91 | end 92 | -------------------------------------------------------------------------------- /lib/wee/presenter.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | # 4 | # Presenter is the superclass of all classes that want to participate in 5 | # rendering and callback-processing. It merely specifies an interface without 6 | # actual implementation. 7 | # 8 | # Class Component and Decoration are it's two most important subclasses. 9 | # 10 | class Presenter 11 | 12 | def render!(r) 13 | r.with(self) {|new_r| render(new_r)} 14 | end 15 | 16 | def render(r); raise end 17 | def state(s); raise end 18 | 19 | def process_callbacks(callbacks); raise end 20 | 21 | # 22 | # Returns the class used as Renderer for this presenter. Overwrite this 23 | # method if you want to use a different Renderer than the default one. 24 | # 25 | # Returned class must be a subclass of Wee::Renderer. 26 | # 27 | def renderer_class 28 | Wee::HtmlCanvas 29 | end 30 | 31 | protected 32 | 33 | # 34 | # Returns the current session. A presenter (or component) has always an 35 | # associated session. The returned object is of class Wee::Session or a 36 | # subclass thereof. 37 | # 38 | def session 39 | Wee::Session.current 40 | end 41 | 42 | end # class Presenter 43 | 44 | end # module Wee 45 | -------------------------------------------------------------------------------- /lib/wee/renderer.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | # 4 | # Base class of all Renderer classes. 5 | # 6 | class Renderer 7 | attr_accessor :session 8 | attr_accessor :request 9 | attr_accessor :response 10 | attr_accessor :callbacks 11 | attr_accessor :document 12 | attr_accessor :current_component 13 | 14 | def initialize(session=nil, request=nil, response=nil, callbacks=nil, document=nil, current_component=nil) 15 | @session = session 16 | @request = request 17 | @response = response 18 | @callbacks = callbacks 19 | @document = document 20 | @current_component = current_component 21 | end 22 | 23 | def with(component) 24 | rclass = component.renderer_class 25 | if rclass == self 26 | # reuse renderer 27 | old_component = @current_component 28 | begin 29 | @current_component = component 30 | yield self 31 | ensure 32 | @current_component = old_component 33 | end 34 | else 35 | close 36 | r = rclass.new(@session, @request, @response, @callbacks, @document, component) 37 | begin 38 | yield r 39 | ensure 40 | r.close 41 | end 42 | end 43 | end 44 | 45 | # 46 | # NOTE: unregister will do nothing for a regular request, only 47 | # for an AJAX request. Only if you would render one and the 48 | # same component twice it would behave differently. 49 | # 50 | def render(component) 51 | close 52 | self.callbacks.unregister(component) 53 | component.decoration.render!(self) 54 | nil 55 | end 56 | 57 | def render_decoration(decoration) 58 | close 59 | self.callbacks.unregister(decoration) 60 | decoration.render!(self) 61 | nil 62 | end 63 | 64 | # 65 | # Subclass responsibility. 66 | # 67 | def close 68 | end 69 | 70 | end # class Renderer 71 | 72 | end # module Wee 73 | -------------------------------------------------------------------------------- /lib/wee/request.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Wee 4 | 5 | class Request < Rack::Request 6 | 7 | def self.new(env) 8 | env['wee.request'] ||= super 9 | end 10 | 11 | attr_reader :fields 12 | attr_accessor :session_id 13 | attr_accessor :page_id 14 | 15 | def initialize(env) 16 | super(env) 17 | @fields = self.params 18 | @session_id = @fields.delete("_s") 19 | @page_id = @fields.delete("_p") 20 | end 21 | 22 | # Is this an action request? 23 | def action? 24 | not render? 25 | end 26 | 27 | # Is this a render request? 28 | def render? 29 | @fields.empty? 30 | end 31 | 32 | alias ajax? xhr? 33 | 34 | def build_url(hash={}) 35 | session_id = hash.has_key?(:session_id) ? hash[:session_id] : @session_id 36 | page_id = hash.has_key?(:page_id) ? hash[:page_id] : @page_id 37 | callback_id = hash[:callback_id] 38 | info = hash.has_key?(:info) ? hash[:info] : @info 39 | 40 | raise ArgumentError if session_id.nil? and not page_id.nil? 41 | raise ArgumentError if page_id.nil? and not callback_id.nil? 42 | 43 | q = {} 44 | q['_s'] = session_id if session_id 45 | q['_p'] = page_id if page_id 46 | q[callback_id] = nil if callback_id 47 | 48 | path = script_name() + (info || path_info()) 49 | path << "?" << Rack::Utils.build_query(q) unless q.empty? 50 | 51 | return path 52 | end 53 | 54 | end # class Request 55 | 56 | end 57 | -------------------------------------------------------------------------------- /lib/wee/response.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | require 'rack' 3 | 4 | module Wee 5 | 6 | class Response < Rack::Response 7 | alias << write 8 | end 9 | 10 | class GenericResponse < Response 11 | EXPIRE_OFFSET = 3600*24*365*20 # 20 years 12 | EXPIRES_HEADER = 'Expires'.freeze 13 | CONTENT_TYPE_HEADER = 'Content-Type'.freeze 14 | CONTENT_TYPE = 'text/html; charset=UTF-8'.freeze 15 | 16 | def initialize(*args) 17 | super 18 | self[EXPIRES_HEADER] ||= (Time.now + EXPIRE_OFFSET).rfc822 19 | self[CONTENT_TYPE_HEADER] = CONTENT_TYPE 20 | end 21 | end 22 | 23 | class RedirectResponse < Response 24 | LOCATION_HEADER = 'Location'.freeze 25 | 26 | def initialize(location) 27 | super(['302 - Redirect

302 - Redirect

', 28 | '

You are being redirected to ', 29 | location, ''], 302, LOCATION_HEADER => location) 30 | end 31 | end 32 | 33 | class RefreshResponse < Response 34 | def initialize(message, location, seconds=5) 35 | super([%[ 36 | 37 | 38 | #{message} 39 | 40 | 41 |

#{message}

42 | You are being redirected to #{location} 43 | in #{seconds} seconds. 44 | 45 | ]]) 46 | end 47 | end 48 | 49 | class NotFoundResponse < Response 50 | def initialize 51 | super(['404 - Not Found

404 - Not Found

'], 404) 52 | end 53 | end 54 | 55 | class ErrorResponse < Response 56 | include Rack::Utils 57 | 58 | def initialize(exception) 59 | super() 60 | self << "Error occured" 61 | self << "

#{ escape_html(@exception.inspect) }
" 62 | self << exception.backtrace.map{|s| escape_html(s)}.join("
") 63 | self << "

" 64 | self << "" 65 | end 66 | end 67 | 68 | end # module Wee 69 | -------------------------------------------------------------------------------- /lib/wee/rightjs.rb: -------------------------------------------------------------------------------- 1 | require 'wee/external_resource' 2 | 3 | class Wee::RightJS < Wee::ExternalResource 4 | def javascripts 5 | mount_path_relative('rightjs-1.5.2.min.js', 'wee-rightjs.js') 6 | end 7 | 8 | def resource_dir 9 | file_relative(__FILE__, 'rightjs') 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/wee/rightjs/rightjs-1.5.2.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * RightJS, http://rightjs.org 3 | * Released under the MIT license 4 | * 5 | * Custom build with options: no-olds 6 | * 7 | * Copyright (C) 2008-2009 Nikolay Nemshilov 8 | */ 9 | var RightJS={version:"1.5.2",modules:["core","form","cookie","xhr","fx"]};var Browser=(function(a){return{IE:!!(window.attachEvent&&!window.opera),Opera:!!window.opera,WebKit:a.indexOf('AppleWebKit/')>-1,Gecko:a.indexOf('Gecko')>-1&&a.indexOf('KHTML')<0,MobileSafari:!!a.match(/Apple.*Mobile.*Safari/),Konqueror:a.indexOf('Konqueror')>-1,OLD:a.indexOf('MSIE 6')>-1||a.indexOf('MSIE 7')>-1,IE8:a.indexOf('MSIE 8')>-1}})(navigator.userAgent);function $ext(d,s,a){var s=s||{};for(var k in s)if(a===undefined||d[k]===undefined)d[k]=s[k];return d};function $try(){for(var i=0;i-1;i--)if(c.call(s,this[i],i,this))return this[i];return undefined};var q=function(a,b){var d=a[0],a=A.slice.call(a,1),s=b;if(isString(d)){var c=d;if(b.length&&isFunction(b[0][c]))d=function(o){return o[c].apply(o,a)};else d=function(o){return o[c]}}else s=a[0];return[d,s]};var g=function(f,s,a){try{return f.apply(s,q(a,s))}catch(e){if(!(e instanceof Break))throw(e)}};return{indexOf:A.indexOf||function(v,f){for(var i=(f<0)?Math.max(0,this.length+f):f||0,l=this.length;i-1;i--)if(this[i]===v)return i;return-1},first:function(){return arguments.length?g(m,this,arguments):this[0]},last:function(){return arguments.length?g(t,this,arguments):this[this.length-1]},random:function(){return this.length?this[Math.random(this.length-1)]:null},size:function(){return this.length},clean:function(){this.length=0;return this},empty:function(){return!this.length},clone:function(){return this.slice(0)},each:function(){g(n,this,arguments);return this},forEach:n,map:function(){return g(u,this,arguments)},filter:function(){return g(k,this,arguments)},some:function(){return g(w,this,arguments.length?arguments:[function(a){return!!a}])},every:function(){return g(h,this,arguments.length?arguments:[function(a){return!!a}])},walk:function(){this.map.apply(this,arguments).forEach(function(v,a){this[a]=v},this);return this},merge:function(){for(var c=this.clone(),a,i=0,l=arguments.length;id.value?1:c.value]+>/ig,'')},stripScripts:function(o){var a='';var t=this.replace(/]*>([\s\S]*?)<\/script>/img,function(m,s){a+=s.trim()+"\n";return ''});if(o===true)$eval(a);else if(isFunction(o))o(a,t);else if(isNumber(o))$eval.bind(a).delay(options);return t},extractScripts:function(){var s='';this.stripScripts(function(a){s=a});return s},evalScripts:function(){$eval(this.extractScripts());return this},camelize:function(){var p=this.match(/^(\-|_)+?/g)||'';return p+this.substr(p.length,this.length).replace(/(\-|_)+?(\D)/g,function(m){return m.replace(/\-|_/,'').toUpperCase()})},underscored:function(){return this.replace(/([a-z0-9])([A-Z]+)/g,function(m,f,s){return f+"_"+(s.length>1?s:s.toLowerCase())}).replace(/\-/g,'_')},capitalize:function(){return this.replace(/(^|\s|\-|_)[a-z\u00e0-\u00fe\u0430-\u045f]/g,function(m){return m.toUpperCase()})},includes:function(s){return this.indexOf(s)!=-1},startsWith:function(a,i){var s=this.substr(0,a.length);return i?s.toLowerCase()===a.toLowerCase():s===a},endsWith:function(s,i){var e=this.substring(this.length-s.length);return i?e.toLowerCase()===s.toLowerCase():e===s},toInt:function(b){return parseInt(this,b||10)},toFloat:function(s){return parseFloat(s?this:this.replace(',','.').replace(/(\d)-(\d)/g,'$1.$2'))}});$alias(String.prototype,{include:'includes'});$ext(Function.prototype,(function(){var A=Array.prototype.slice;return{bind:function(){if(arguments.length<2&&!arguments[0])return this;var b=A,a=b.call(arguments),s=a.shift(),f=this;return function(){return f.apply(s,(a.length!=0||arguments.length!=0)?a.concat(b.call(arguments)):a)}},bindAsEventListener:function(){var b=A,a=b.call(arguments),s=a.shift(),f=this;return function(e){return f.apply(s,[e||window.event].concat(a).concat(b.call(arguments)))}},curry:function(){return this.bind.apply(this,[this].concat(A.call(arguments)))},rcurry:function(){var c=A.call(arguments),f=this;return function(){return f.apply(f,A.call(arguments).concat(c))}},delay:function(){var a=A.call(arguments),t=a.shift();var b=new Number(window.setTimeout(this.bind.apply(this,[this].concat(a)),t));b.cancel=function(){window.clearTimeout(this)};return b},periodical:function(){var a=A.call(arguments),t=a.shift();var b=new Number(window.setInterval(this.bind.apply(this,[this].concat(a)),t));b.stop=function(){window.clearInterval(this)};return b},chain:function(){var a=A.call(arguments),f=a.shift(),c=this;return function(){var r=c.apply(c,arguments);f.apply(f,a);return r}}}})());$ext(Number.prototype,{times:function(c,s){for(var i=0;i=n;i--)c.call(s,i);return this},abs:function(){return Math.abs(this)},round:function(b){if(b){var b=Math.pow(10,b);return Math.round(this*b)/b}else return Math.round(this)},ceil:function(){return Math.ceil(this)},floor:function(){return Math.floor(this)}});$ext(RegExp,{escape:function(s){return String(s).replace(/([.*+?^=!:${}()|[\]\/\\])/g,'\\$1')}});var Class=function(){var a=$A(arguments),b=a.pop()||{},p=a.pop();if(!a.length&&!isHash(b)){p=b;b={}}var k=function(){return this.initialize?this.initialize.apply(this,arguments):this};$ext(k,Class.Methods).inherit(p);$w('extend include').each(function(n){if(b[n]){var m=b[n];k[n].apply(k,isArray(m)?m:[m]);delete(b[n])}});return k.include(b)};Class.findSet=function(o,p){var u=p.toUpperCase(),b=p.capitalize(),c=[o,o.constructor].concat(o.constructor.ancestors),h=c.first(function(a){return a[u]||a[b]});return h?h[u]||h[b]:null};Class.Methods=(function(){var f=$w('selfExtended self_extended selfIncluded self_included');var g=f.concat($w('prototype parent extend include'));var i=f.concat(['constructor']);var e=function(m,w){return Object.without.apply(Object,[m].concat(w=='e'?g:i))};return{inherit:function(p){if(p&&p.prototype){var s=function(){};s.prototype=p.prototype;this.prototype=new s;this.parent=p}this.ancestors=[];while(p){this.ancestors.push(p);p=p.parent}return this.prototype.constructor=this},extend:function(){$A(arguments).filter(isHash).each(function(m){var c=m.selfExtended||m.self_extended;$ext(this,e(m,'e'));if(c)c.call(m,this)},this);return this},include:function(){var d=this.ancestors.map('prototype'),b;$A(arguments).filter(isHash).each(function(a){var c=a.selfIncluded||a.self_included;a=e(a,'i');for(var k in a){b=d.first(function(p){return isFunction(p[k])});this.prototype[k]=!b?a[k]:(function(n,m,s){return function(){this.$super=s;return m.apply(this,arguments)}})(k,a[k],b[k])}if(c)c.call(a,this)},this);return this}}})();var Options={setOptions:function(o){var o=this.options=Object.merge(Class.findSet(this,'options'),o);if(isFunction(this.on)){var m;for(var k in o)if(m=k.match(/on([A-Z][A-Za-z]+)/)){this.on(m[1].toLowerCase(),o[k]);delete(o[k])}}return this},cutOptions:function(a){var a=$A(a);this.setOptions(isHash(a.last())?a.pop():{});return a}};var Observer=new Class({include:Options,initialize:function(o){this.setOptions(o);Observer.createShortcuts(this,Class.findSet(this,'events'))},observe:function(){var a=$A(arguments),e=a.shift();if(isString(e)){if(!defined(this.$listeners))this.$listeners=[];var c=a.shift(),n;switch(typeof(c)){case "string":n=c;c=this[c];case "function":var h={};h.e=e;h.f=c;h.a=a;h.r=n;this.$listeners.push(h);break;default:if(isArray(c))c.each(function(p){this.observe.apply(this,[e].concat(isArray(p)?p:[p]).concat(a))},this)}}else for(var n in e)this.observe.apply(this,[n].concat(isArray(e[n])?e[n]:[e[n]]).concat(a));return this},observes:function(e,c){if(this.$listeners){if(!isString(e)){c=e;e=null}if(isString(c))c=this[c];return this.$listeners.some(function(a){return(e&&c)?a.e==e&&a.f==c:e?a.e==e:a.f==c})}return false},stopObserving:function(e,c){if(this.$listeners){if(!isString(e)){c=e;e=null}if(isString(c))c=this[c];this.$listeners=this.$listeners.filter(function(a){return(e&&c)?(a.e!==e||a.f!==c):(e?a.e!==e:a.f!==c)},this)}return this},listeners:function(e){return(this.$listeners||[]).filter(function(a){return!e||a.e===e}).map(function(a){return a.f}).uniq()},fire:function(){var b=$A(arguments),e=b.shift();(this.$listeners||[]).each(function(a){if(a.e===e)a.f.apply(this,a.a.concat(b))},this);return this},extend:{create:function(o,e){$ext(o,Object.without(this.prototype,'initialize','setOptions'),true);return this.createShortcuts(o,e||Class.findSet(o,'events'))},createShortcuts:function(o,a){(a||[]).each(function(n){var s={},m=n.replace(/:/g,'_').camelize();s[m]=function(){return this.fire.apply(this,[n].concat($A(arguments)))};s['on'+m.capitalize()]=function(){return this.on.apply(this,[n].concat($A(arguments)))};$ext(o,s,true)});return o}}});$alias(Observer.prototype,{on:'observe'});var Break=new Class(Error,{message:"Manual iterator break"});var Event=new Class(Event,{extend:{ext:function(e){if(!e.stop){$ext(e,this.Methods,true);if(Browser.IE){if(e.type=='click'||e.type=='dblclick')e.which=1;else if(e.type=='contextmenu')e.which=3;else e.which=e.button==2?3:e.button==4?2:1;var s=window.scrolls();e.pageX=e.clientX+s.x;e.pageY=e.clientY+s.y;e.relatedTarget=e.type=='mouseover'?e.fromEvent:e.type=='mouseout'?e.toEvent:null;e.target=e.srcElement}}if(e.target&&e.target.nodeType==3)e.target=e.target.parentNode;return e},cleanName:function(n){n=n.toLowerCase();n=n.substr(0,2)==='on'?n.slice(2):n;n=n==='rightclick'?'contextmenu':n;return n},realName:function(n){if(Browser.Gecko&&n==='mousewheel')n='DOMMouseScroll';if(Browser.Konqueror&&n==='contextmenu')n='rightclick';return n},addMethods:function(m){$ext(this.Methods,m);try{$ext(Event.parent.prototype,m,true)}catch(e){}},Methods:{}},initialize:function(n,o){return new Event.Custom(Event.cleanName(n),o)}});Event.addMethods({stopPropagation:function(){this.cancelBubble=true},preventDefault:function(){this.returnValue=false},stop:function(){this.stopPropagation();this.preventDefault();return this},position:function(){return{x:this.pageX,y:this.pageY}}});Event.Custom=function(n,o){this.type=n;this.stop=function(){};$ext(this,o||{})};self.Element=(function(a){var n=function(t,o){var e=document.createElement(t),o=o||{};if(o.id){e.id=o.id;delete(o.id)}if(o.html){e.innerHTML=o.html;delete(o.html)}if(o['class']){e.className=o['class'];delete(o['class'])}if(o.style){e.setStyle(o.style);delete(o.style)}if(o.observe){e.observe(o.observe);delete(o.observe)}for(var k in o)return e.set(o);return e};if(Browser.IE)n=eval('({f:'+n.toString().replace(/(\((\w+), (\w+)\) \{)/,'$1if($2=="input"&&$3&&$3.checked)$2="";')+'})').f;if(a){$ext(n,a);n.parent=a}return n})(self.Element);$ext(Element,{addMethods:function(m,d){$ext(this.Methods,m,d);try{$ext(HTMLElement.prototype,m,d)}catch(e){try{$ext(this.parent.prototype,m,d)}catch(e){}}return this},Methods:{}});Element.addMethods({parent:function(c){return c?this.parents(c)[0]:$(this.parentNode)},parents:function(c){return this.rCollect('parentNode',c)},subNodes:function(c){var f=this.firstChild;return f?(f.tagName?[$(f)]:[]).concat(this.rCollect.call(f,'nextSibling',c)):[]},siblings:function(c){return this.prevSiblings(c).reverse().concat(this.nextSiblings(c))},nextSiblings:function(c){return this.rCollect('nextSibling',c)},prevSiblings:function(c){return this.rCollect('previousSibling',c)},next:function(c){return this.nextSiblings(c)[0]},prev:function(c){return this.prevSiblings(c)[0]},remove:function(){if(this.parentNode)this.parentNode.removeChild(this);return this},insert:function(c,p){if(isHash(c))for(var p in c)this.insert(c[p],p);else{var s,i=Element.insertions;p=isString(p)?p.toLowerCase():'bottom';if(isString(c))c=c.stripScripts(function(a){s=a});i[p](this,c.tagName?c:i.createFragment.call((p==='bottom'||p==='top'||!this.parentNode)?this:this.parentNode,c));if(s)$eval(s)}return this},insertTo:function(e,p){$(e).insert(this,p);return this},replace:function(c){return this.insert(c,'instead')},update:function(c){if(isString(c)){var s;this.innerHTML=c.stripScripts(function(a){s=a});if(s)$eval(s)}else this.clean().insert(c);return this},wrap:function(e){if(this.parentNode){this.parentNode.replaceChild(e,this);e.appendChild(this)}return this},clean:function(){while(this.firstChild)this.removeChild(this.firstChild);return this},empty:function(){return this.innerHTML.blank()},rCollect:function(a,c){var n=this,r=[];while((n=n[a]))if(n.tagName&&(!c||$(n).match(c)))r.push(n);return r}});Element.insertions={bottom:function(t,c){t.appendChild(c)},top:function(t,c){t.firstChild?t.insertBefore(c,t.firstChild):t.appendChild(c)},after:function(t,c){var p=t.parentNode,s=t.nextSibling;if(p)s?p.insertBefore(c,s):p.appendChild(c)},before:function(t,c){if(t.parentNode)t.parentNode.insertBefore(c,t)},instead:function(t,c){if(t.parentNode)t.parentNode.replaceChild(c,t)},createFragment:function(c){var f;if(isString(c)){var t=document.createElement('div'),w=Element.insertions.wraps[this.tagName]||['','',0],d=w[2];t.innerHTML=w[0]+c+w[1];while(d>0){t=t.firstChild;d--}f=arguments.callee.call(this,t.childNodes)}else{f=document.createDocumentFragment();if(isNode(c))f.appendChild(c);else if(c&&c.length)for(var i=0,l=c.length;i','',1],TBODY:['','
',2],TR:['','
',3],TD:['
','
',4],SELECT:['',1]}};$alias(Element.insertions.wraps,{THEAD:'TBODY',TFOOT:'TBODY',TH:'TD'});Element.addMethods({setStyle:function(h,v){if(v){var s={};s[h]=v;h=s}else if(isString(h)){var s={};h.split(';').each(function(o){var e=o.split(':').map('trim');if(e[0]&&e[1])s[e[0]]=e[1]});h=s}var c;for(var k in h){c=k.indexOf('-')!=-1?k.camelize():k;if(k==='opacity'){if(Browser.IE)this.style.filter='alpha(opacity='+v*100+')';else this.style.opacity=v}else if(k==='float')c=Browser.IE?'styleFloat':'cssFloat';this.style[c]=h[k]}return this},getStyle:function(k){return this._getStyle(this.style,k)||this._getStyle(this.computedStyles(),k)},computedStyles:function(){return this.currentStyle||this.runtimeStyle||this.ownerDocument.defaultView.getComputedStyle(this,null)||{}},_getStyle:function(s,k){var v,k=k.camelize();switch(k){case 'opacity':v=!Browser.IE?s[k]:((/opacity=(\d+)/i.exec(s.filter||'')||['','100'])[1].toInt()/100)+'';break;case 'float':k=Browser.IE?'styleFloat':'cssFloat';default:v=s[k];if(Browser.Opera&&/color/i.test(k)&&v)v=v.replace(/"/g,'')}return v?v:null},hasClass:function(n){return(' '+this.className+' ').indexOf(' '+n+' ')!=-1},setClass:function(c){this.className=c;return this},addClass:function(n){var t=' '+this.className+' ';if(t.indexOf(' '+n+' ')==-1)this.className+=(t===' '?'':' ')+n;return this},removeClass:function(n){this.className=(' '+this.className+' ').replace(' '+n+' ',' ').trim();return this},toggleClass:function(n){return this[this.hasClass(n)?'removeClass':'addClass'](n)},radioClass:function(n){this.siblings().each('removeClass',n);return this.addClass(n)}});Element.addMethods({set:function(h,a){if(a){var v={};v[h]=a;h=v}for(var k in h){if(this[k]===undefined)this.setAttribute(k,''+h[k]);this[k]=h[k]}return this},get:function(n){var v=this[n]||this.getAttribute(n);return v===''?null:v},has:function(n){return this.get(n)!==null},erase:function(n){this.removeAttribute(n);return this},hidden:function(){return this.getStyle('display')=='none'},visible:function(){return!this.hidden()},hide:function(e,o){this._$pd=this.getStyle('display');this.style.display='none';return this},show:function(e,o){var v=this.tagName=='DIV'?'block':'inline';this.style.display=this._$pd=='none'?v:this._$pd||v;return this},toggle:function(e,o){return this[this.hidden()?'show':'hide'](e,o)},radio:function(e,o){this.siblings().each('hide',e,o);return this.show()}});Element.addMethods({sizes:function(){return{x:this.offsetWidth,y:this.offsetHeight}},position:function(){var r=this.getBoundingClientRect(),d=this.ownerDocument.documentElement,s=window.scrolls();return{x:r.left+s.x-d.clientLeft,y:r.top+s.y-d.clientTop}},scrolls:function(){return{x:this.scrollLeft,y:this.scrollTop}},dimensions:function(){var a=this.sizes();var s=this.scrolls();var p=this.position();return{top:p.y,left:p.x,width:a.x,height:a.y,scrollLeft:s.x,scrollTop:s.y}},setWidth:function(w){var s=this.style,p='offsetWidth';s.width=w+'px';s.width=(2*w-this[p])+'px';return this},setHeight:function(h){var s=this.style,p='offsetHeight';s.height=h+'px';s.height=(2*h-this[p])+'px';return this},resize:function(w,h){if(isHash(w)){h=w.y;w=w.x}return this.setWidth(w).setHeight(h)},moveTo:function(l,t){if(isHash(l)){t=l.y;l=l.x}return this.setStyle({left:l+'px',top:t+'px'})},scrollTo:function(l,t){if(isHash(l)){t=l.y;l=l.x}this.scrollLeft=l;this.scrollTop=t;return this},scrollThere:function(){window.scrollTo(this);return this}});Element.addMethods((function(){var o=Observer.create({},$w('click rightclick contextmenu mousedown mouseup mouseover mouseout mousemove keypress keydown keyup'));o.observe=o.on=eval('({f:'+o.observe.toString().replace(/(\$listeners\.push\((\w+?)\);)/,'$1'+'$2.e=Event.cleanName($2.e);$2.n=Event.realName($2.e);'+'$2.w=function(){var a=$A(arguments),e=($2.r&&$2.r!=="stopEvent")?a.shift():Event.ext(a[0]);'+'return $2.f.apply(this,a.concat($2.a))};'+(self.attachEvent?'$2.w=$2.w.bind(this);this.attachEvent("on"+$2.n,$2.w);':'this.addEventListener($2.n,$2.w,false);'))+'})').f;o.stopObserving=eval('({f:'+o.stopObserving.toString().replace(/(function\s*\((\w+)\)\s*\{\s*)(return\s*)([^}]+)/m,'$1var r=$4;'+'if(!r)'+(self.attachEvent?'this.detachEvent("on"+$2.n,$2.w);':'this.removeEventListener($2.n,$2.w,false);')+'$3 r')+'})').f;o.fire=eval('({f:'+o.fire.toString().replace(/(\w+)\.f\.apply.*?\.concat\((\w+)\)[^}]/,'$1.f.apply(this,[new Event($1.e,$2.shift())].concat($1.a).concat($2))')+'})').f;o.stopEvent=function(a){a.stop()};$ext(window,o);$ext(document,o);return o})());Element.addMethods((function(){var s=function(c,t){return c?c.replace(/(^|,)/g,'$1'+t+' '):'*'};return{first:function(c){return this.querySelector(s(c,this.tagName))},select:function(c){return $A(this.querySelectorAll(s(c,this.tagName)))},match:function(c){if(!c||c=='*')return true;var f,r,p,a=this.parents();p=a.length?a.last():f=$E('div').insert(this);r=p.select(c).include(this);if(f)this.remove();return r}}})());$ext(document,{first:function(c){return this.querySelector(c||'*')},select:function(c){return $A(this.querySelectorAll(c||'*'))}});$ext(self,(function(){var e=window.scrollTo;return{sizes:function(){var d=document.documentElement;return this.innerWidth?{x:this.innerWidth,y:this.innerHeight}:{x:d.clientWidth,y:d.clientHeight}},scrolls:function(){var b=this.document.body,d=this.document.documentElement,o='pageXOffset',a='pageYOffset',s='scrollLeft',c='scrollTop';return(this[o]||this[a])?{x:this[o],y:this[a]}:(b[s]||b[c])?{x:b[s],y:b[c]}:{x:d[s],y:d[c]}},scrollTo:function(l,t){if(isElement(l)||(isString(l)&&$(l)))l=$(l).position();if(isHash(l)){t=l.y;l=l.x}e(l,t);return this}}})());[window,document].each(function(o){Observer.createShortcuts(o,['ready']);var r=o.ready.bind(o);if(document.readyState!==undefined)(function(){['loaded','complete'].includes(document.readyState)?r():arguments.callee.delay(50)})();else document.addEventListener('DOMContentLoaded',r,false)});var Form=function(o){var o=o||{},r=o.remote,f=new Element('form',Object.without(o,'remote'));if(r)f.remotize();return f};$ext(Form,{ext:function(e){return $ext(e,this.Methods)},Methods:{},addMethods:function(m,d){$ext(Form.Methods,m,d);try{$ext(HTMLFormElement.prototype,m,d)}catch(e){}}});Form.addMethods({getElements:function(){return this.select('input,select,textarea,button')},inputs:function(){return this.getElements().filter(function(i){return!['submit','button','reset','image',null].includes(i.type)})},focus:function(){var f=this.inputs().first(function(i){return i.type!='hidden'});if(f)f.focus();return this.fire('focus')},blur:function(){this.getElements().each('blur');return this.fire('blur')},disable:function(){this.getElements().each('disable');return this.fire('disable')},enable:function(){this.getElements().each('enable');return this.fire('enable')},values:function(){var v={};this.inputs().each(function(i){if(!i.disabled&&i.name&&(!['checkbox','radio'].includes(i.type)||i.checked))v[i.name]=i.getValue()});return v},serialize:function(){return Object.toQueryString(this.values())}});Form.addMethods(Observer.createShortcuts({},$w('submit reset focus')),true);(function(){try{var i=[HTMLInputElement,HTMLSelectElement,HTMLTextAreaElement,HTMLButtonElement]}catch(e){var i=[]}Form.Element={ext:function(e){e._blur=e.blur;e._focus=e.focus;e._select=e.select;return $ext(e,this.Methods)},Methods:{},addMethods:function(m,d){$ext(this.Methods,m,d);i.each(function(k){$ext(k.prototype,m,d)})}};i.each(function(k){$alias(k.prototype,{_blur:'blur',_focus:'focus',_select:'select'})})})();Form.Element.addMethods({getValue:function(){if(this.type=='select-multiple')return $A(this.getElementsByTagName('option')).map(function(o){return o.selected?o.value:null}).compact();else return this.value},setValue:function(v){if(this.type=='select-multiple'){v=(isArray(v)?v:[v]).map(String);$A(this.getElementsByTagName('option')).each(function(o){o.selected=v.includes(o.value)})}else this.value=v;return this},disable:function(){this.disabled=true;this.fire('disable');return this},enable:function(){this.disabled=false;this.fire('enable');return this},focus:function(){Browser.OLD?this._focus():this._focus.call(this);this.focused=true;this.fire('focus');return this},select:function(){this.focus();Browser.OLD?this._select():this._select.call(this);return this},blur:function(){Browser.OLD?this._blur():this._blur.call(this);this.focused=false;this.fire('blur');return this}});Form.Element.addMethods(Observer.createShortcuts({},$w('disable enable focus blur change')),true);var Cookie=new Class({include:Options,extend:{set:function(n,v,o){return new this(n,o).set(v)},get:function(n){return new this(n).get()},remove:function(n){return new this(n).remove()},enabled:function(){document.cookie="__t=1";return document.cookie.indexOf("__t=1")!=-1},Options:{secure:false,document:document}},initialize:function(n,o){this.name=n;this.setOptions(o)},set:function(v){var v=encodeURIComponent(v),o=this.options;if(o.domain)v+='; domain='+o.domain;if(o.path)v+='; path='+o.path;if(o.duration){var d=new Date();d.setTime(d.getTime()+o.duration*24*60*60*1000);v+='; expires='+d.toGMTString()}if(o.secure)v+='; secure';o.document.cookie=this.name+'='+v;return this},get:function(){var v=this.options.document.cookie.match('(?:^|;)\\s*'+RegExp.escape(this.name)+'=([^;]*)');return(v)?decodeURIComponent(v[1]):null},remove:function(){this.options.duration=-1;return this.set('')}});var Xhr=new Class(Observer,{extend:{EVENTS:$w('success failure complete request cancel create'),Options:{headers:{'X-Requested-With':'XMLHttpRequest','Accept':'text/javascript,text/html,application/xml,text/xml,*/*'},method:'post',encoding:'utf-8',async:true,evalScripts:false,evalResponse:false,evalJSON:true,secureJSON:true,urlEncoded:true,spinner:null,spinnerFx:'fade',params:null,iframed:false},load:function(u,o){return new this(u,Object.merge({method:'get'},o)).send()}},initialize:function(u,o){this.initCallbacks();this.url=u;this.$super(o);for(var k in Xhr.Options)this[k]=this.options[k];this.initSpinner()},setHeader:function(n,v){this.headers[n]=v;return this},getHeader:function(n){try{return this.xhr.getResponseHeader(n)}catch(e){}},successful:function(){return(this.status>=200)&&(this.status<300)},send:function(p){var a={},u=this.url,m=this.method.toLowerCase();if(m=='put'||m=='delete'){a['_method']=m;m='post'}var d=this.prepareData(this.params,this.prepareParams(p),a);if(this.urlEncoded&&m=='post'&&!this.headers['Content-type'])this.setHeader('Content-type','application/x-www-form-urlencoded;charset='+this.encoding);if(m=='get'){u+=(u.includes('?')?'&':'?')+d;d=null}this.xhr=this.createXhr();this.fire('create');this.xhr.open(m,u,this.async);this.xhr.onreadystatechange=this.stateChanged.bind(this);for(var k in this.headers)this.xhr.setRequestHeader(k,this.headers[k]);this.xhr.send(d);this.fire('request');if(!this.async)this.stateChanged();return this},update:function(e,p){return this.onSuccess(function(a){e.update(a.text)}).send(p)},cancel:function(){if(!this.xhr||this.xhr.canceled)return this;this.xhr.abort();this.xhr.onreadystatechange=function(){};this.xhr.canceled=true;return this.fire('cancel')},fire:function(n){return this.$super(n,this,this.xhr)},createXhr:function(){if(this.form&&this.form.getElements().map('type').includes('file'))return new Xhr.IFramed(this.form);else try{return new XMLHttpRequest()}catch(e){return new ActiveXObject('MSXML2.XMLHTTP')}},prepareParams:function(p){if(p&&p.tagName=='FORM'){this.form=p;p=p.values()}return p},prepareData:function(){return $A(arguments).map(function(p){if(!isString(p))p=Object.toQueryString(p);return p.blank()?null:p}).compact().join('&')},stateChanged:function(){if(this.xhr.readyState!=4||this.xhr.canceled)return;try{this.status=this.xhr.status}catch(e){this.status=0}this.text=this.responseText=this.xhr.responseText;this.xml=this.responseXML=this.xhr.responseXML;this.fire('complete').fire(this.successful()?'success':'failure')},tryScripts:function(r){if(this.evalResponse||(/(ecma|java)script/).test(this.getHeader('Content-type')))$eval(this.text);else if((/json/).test(this.getHeader('Content-type'))&&this.evalJSON)this.json=this.responseJSON=this.sanitizedJSON();else if(this.evalScripts)this.text.evalScripts()},sanitizedJSON:function(){if(!(/^[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t]*$/).test(this.text.replace(/\\./g,'@').replace(/"[^"\\\n\r]*"/g,''))){if(this.secureJSON)throw "JSON parse error: "+this.text;else return null}return eval("("+this.text+")")},initCallbacks:function(){this.on({success:'tryScripts',create:'showSpinner',complete:'hideSpinner',cancel:'hideSpinner'});Xhr.EVENTS.each(function(n){this.on(n,function(){Xhr.fire(n,this,this.xhr)})},this)},initSpinner:function(){if(this.spinner)this.spinner=$(this.spinner);if(Xhr.Options.spinner&&this.spinner===$(Xhr.Options.spinner))this.spinner=null},showSpinner:function(){if(this.spinner)this.spinner.show(this.spinnerFx,{duration:100})},hideSpinner:function(){if(this.spinner)this.spinner.hide(this.spinnerFx,{duration:100})}});Observer.create(Xhr);$ext(Xhr,{counter:0,showSpinner:function(){if(this.Options.spinner)$(this.Options.spinner).show(this.Options.spinnerFx,{duration:100})},hideSpinner:function(){if(this.Options.spinner)$(this.Options.spinner).hide(this.Options.spinnerFx,{duration:100})}});Xhr.onCreate(function(){this.counter++;this.showSpinner()}).onComplete(function(){this.counter--;if(this.counter<1)this.hideSpinner()}).onCancel(function(){this.counter--;if(this.counter<1)this.hideSpinner()});Form.addMethods({send:function(o){o=o||{};o['method']=o['method']||this.method||'post';new Xhr(this.get('action')||document.location.href,o).onRequest(this.disable.bind(this)).onComplete(this.enable.bind(this)).send(this);return this},remotize:function(o){this.onsubmit=function(){this.send.bind(this,Object.merge({spinner:this.first('.spinner')},o)).delay(20);return false};this.remote=true;return this},unremotize:function(){this.onsubmit=function(){};this.remote=false;return this}});Element.addMethods({load:function(u,o){new Xhr(u,Object.merge({method:'get'},o)).update(this);return this}});Xhr.IFramed=new Class({initialize:function(f){this.form=f;var i='xhr_frame_'+Math.random().toString().split('.').last();$E('div').insertTo(document.body).update('');this.iframe=$(i);this.iframe.on('load',this.onLoad.bind(this))},send:function(){var o=this.form.onsubmit,a=this.form.target;this.form.onsubmit=function(){};this.form.target=this.iframe.id;this.form.submit();this.form.onsubmit=o;this.form.target=a},onLoad:function(){this.status=200;this.readyState=4;var d=window[this.iframe.id].document.documentElement;this.responseText=d?d.innerHTML:null;this.onreadystatechange()},open:function(){},abort:function(){},setRequestHeader:function(){},onreadystatechange:function(){}});var Fx=new Class(Observer,{extend:{EVENTS:$w('start finish cancel'),Durations:{'short':200,'normal':400,'long':800},Options:{fps:Browser.IE?40:60,duration:'normal',transition:'Sin',queue:true},Transitions:{Sin:function(a){return-(Math.cos(Math.PI*a)-1)/2},Cos:function(a){return Math.asin((a-0.5)*2)/Math.PI+0.5},Exp:function(a){return Math.pow(2,8*(a-1))},Log:function(a){return 1-Math.pow(2,-8*a)},Lin:function(a){return a}}},initialize:function(e,o){this.$super(o);this.element=$(e)},start:function(){if(this.queue(arguments))return this;this.prepare.apply(this,arguments);var o=this.options,d=Fx.Durations[o.duration]||o.duration;this.transition=Fx.Transitions[o.transition]||o.transition;this.steps=(d/1000*this.options.fps).ceil();this.number=1;return this.fire('start',this).startTimer()},finish:function(){return this.stopTimer().fire('finish').next()},cancel:function(){return this.stopTimer().fire('cancel').next()},pause:function(){return this.stopTimer()},resume:function(){return this.startTimer()},prepare:function(v){},render:function(d){},step:function(t){if(t.number>t.steps)t.finish();else{if(!t.w){t.w=true;t.render(t.transition(t.number/t.steps));t.w=false}t.number++}},startTimer:function(){this.timer=this.step.periodical((1000/this.options.fps).round(),this);return this},stopTimer:function(){if(this.timer)this.timer.stop();return this},queue:function(a){if(!this.element)return false;if(this.$ch)return this.$ch=false;var u=$uid(this.element),c;Fx.$ch=Fx.$ch||[];c=(Fx.$ch[u]=Fx.$ch[u]||[]);if(this.options.queue)c.push([a,this]);this.next=function(){var n=c.shift();n=c[0];if(n){n[1].$ch=true;n[1].start.apply(n[1],n[0])}return this};return this.options.queue&&c[0][1]!==this},next:function(){return this}});String.COLORS={maroon:'#800000',red:'#ff0000',orange:'#ffA500',yellow:'#ffff00',olive:'#808000',purple:'#800080',fuchsia:'#ff00ff',white:'#ffffff',lime:'#00ff00',green:'#008000',navy:'#000080',blue:'#0000ff',aqua:'#00ffff',teal:'#008080',black:'#000000',silver:'#c0c0c0',gray:'#808080',brown:'#a52a2a'};$ext(String.prototype,{toHex:function(){var m=/^#(\w)(\w)(\w)$/.exec(this);if(m)m="#"+m[1]+m[1]+m[2]+m[2]+m[3]+m[3];else if(m=/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(this))m="#"+m.slice(1).map(function(b){b=(b-0).toString(16);return b.length==1?'0'+b:b}).join('');else m=String.COLORS[this]||this;return m},toRgb:function(a){var m=/#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i.exec(this.toHex()||'');if(m){m=m.slice(1).map('toInt',16);m=a?m:'rgb('+m+')'}return m}});Fx.Morph=new Class(Fx,(function(){var C='Color',S='Style',W='Width',B='background',o='border',P='Position',h=B+C,t=$w('Top Left Right Bottom');var p=function(a,k,v){for(var i=0;i')})(); -------------------------------------------------------------------------------- /lib/wee/rightjs/wee-rightjs.js: -------------------------------------------------------------------------------- 1 | var wee = {}; 2 | 3 | wee._update_elements = function(e) { 4 | var id = e.get('id'); 5 | if (id) 6 | $(id).update(e); 7 | else 8 | e.insertTo(document.body); 9 | }; 10 | 11 | wee._update_callback = function(r) { 12 | new Element('div', {html: r.text}).subNodes().each(wee._update_elements); 13 | }; 14 | 15 | wee.update = function(url) { 16 | new Xhr(url, {method: 'get'}).onSuccess(wee._update_callback).send(); 17 | return false; 18 | }; 19 | -------------------------------------------------------------------------------- /lib/wee/root_component.rb: -------------------------------------------------------------------------------- 1 | require 'wee/component' 2 | require 'wee/external_resource' 3 | 4 | module Wee 5 | 6 | # 7 | # A RootComponent has a special instanciate class method that makes it more 8 | # comfortable for root components. 9 | # 10 | class RootComponent < Component 11 | 12 | def self.run(*params, &block) 13 | Wee.run(self, *params, &block) 14 | end 15 | 16 | def title 17 | self.class.name.to_s 18 | end 19 | 20 | # 21 | # Returns an array of ExternalResource objects required for this 22 | # RootComponent. 23 | # 24 | def self.external_resources 25 | self.depends.flatten.select {|cls| cls <= Wee::ExternalResource }.uniq. 26 | map {|cls| cls.new } 27 | end 28 | 29 | def stylesheets 30 | self.class.external_resources.map {|ext_res| ext_res.stylesheets}.flatten 31 | end 32 | 33 | def javascripts 34 | self.class.external_resources.map {|ext_res| ext_res.javascripts}.flatten 35 | end 36 | 37 | def self.instanciate(*args, &block) 38 | obj = new(*args, &block) 39 | obj.add_decoration Wee::PageDecoration.new(obj.title, obj.stylesheets, obj.javascripts) 40 | return obj 41 | end 42 | 43 | end # class RootComponent 44 | 45 | end # module Wee 46 | -------------------------------------------------------------------------------- /lib/wee/run.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | def Wee.run(component_class=nil, params=nil, &block) 4 | raise ArgumentError if component_class and block 5 | 6 | params ||= Hash.new 7 | params[:mount_path] ||= '/' 8 | params[:port] ||= 2000 9 | params[:public_path] ||= nil 10 | params[:additional_builder_procs] ||= [] 11 | params[:use_continuations] ||= true 12 | params[:print_message] ||= false 13 | params[:autoreload] ||= false 14 | 15 | if component_class <= Wee::RootComponent 16 | component_class.external_resources.each do |ext_res| 17 | params[:additional_builder_procs] << proc {|builder| ext_res.install(builder)} 18 | end 19 | end 20 | 21 | raise ArgumentError if params[:use_continuations] and block 22 | 23 | unless block 24 | block ||= if params[:use_continuations] 25 | proc { Wee::Session.new(component_class.instanciate, 26 | Wee::Session::ThreadSerializer.new) } 27 | else 28 | proc { Wee::Session.new(component_class.instanciate) } 29 | end 30 | end 31 | 32 | app = Rack::Builder.app do 33 | map params[:mount_path] do 34 | a = Wee::Application.new(&block) 35 | 36 | if params[:auth_md5] 37 | a = Rack::Auth::Digest::MD5.new(a, ¶ms[:auth_md5]) 38 | a.realm = params[:auth_realm] || 'Wee App' 39 | a.opaque = params[:auth_md5_opaque] || Wee::IdGenerator::Secure.new.next 40 | end 41 | 42 | if params[:auth_basic] 43 | a = Rack::Auth::Basic.new(a, params[:auth_realm] || 'Wee App', ¶ms[:auth_basic]) 44 | end 45 | 46 | if params[:autoreload] 47 | if params[:autoreload].kind_of?(Integer) 48 | timer = Integer(params[:autoreload]) 49 | else 50 | timer = 0 51 | end 52 | use Rack::Reloader, timer 53 | end 54 | 55 | if params[:public_path] 56 | run Rack::Cascade.new([Rack::File.new(params[:public_path]), a]) 57 | else 58 | run a 59 | end 60 | end 61 | params[:additional_builder_procs].each {|bproc| bproc.call(self)} 62 | end 63 | 64 | if params[:print_message] 65 | url = "http://localhost:#{params[:port]}#{params[:mount_path]}" 66 | io = params[:print_message].kind_of?(IO) ? params[:print_message] : STDERR 67 | io.puts 68 | io.puts "Open your browser at: #{url}" 69 | io.puts 70 | end 71 | 72 | Rack::Handler::WEBrick.run(app, :Port => params[:port]) 73 | end 74 | -------------------------------------------------------------------------------- /lib/wee/session.rb: -------------------------------------------------------------------------------- 1 | require 'thread' 2 | require 'wee/lru_cache' 3 | require 'wee/id_generator' 4 | require 'wee/renderer' 5 | 6 | module Wee 7 | 8 | class Session 9 | 10 | include LRUCache::Item 11 | 12 | # 13 | # The default serializer, when no continuations are going to be used. 14 | # Ensures that only one request of the same session is executed at 15 | # the same time. 16 | # 17 | class MutexSerializer < Mutex 18 | def call(env) 19 | synchronize { env['wee.session'].call(env) } 20 | end 21 | end 22 | 23 | # 24 | # This serializer ensures that all requests of a session are 25 | # executed within the same thread. This is required if continuations 26 | # are going to be used. 27 | # 28 | # You can run multiple sessions within the same ThreadSerializer, or 29 | # allocate one ThreadSerializer (and as such one Thread) per session 30 | # as you want. 31 | # 32 | class ThreadSerializer 33 | def initialize 34 | @in, @out = Queue.new, Queue.new 35 | @thread = Thread.new { 36 | Thread.abort_on_exception = true 37 | while true 38 | env = @in.pop 39 | @out.push(env['wee.session'].call(env)) 40 | end 41 | } 42 | end 43 | 44 | def call(env) 45 | @in.push(env) 46 | @out.pop 47 | end 48 | end 49 | 50 | class Page 51 | attr_accessor :id, :state, :callbacks 52 | include LRUCache::Item 53 | def initialize(id=nil, state=nil, callbacks=nil) 54 | @id, @state, @callbacks = id, state, callbacks 55 | end 56 | end 57 | 58 | class AbortProcessing < Exception 59 | attr_reader :response 60 | def initialize(response) 61 | @response = response 62 | end 63 | end 64 | 65 | # 66 | # The (application-wide) unique id of this session. 67 | # 68 | attr_accessor :id 69 | 70 | # 71 | # Points to the Wee::Application object this session belongs to. 72 | # 73 | attr_accessor :application 74 | 75 | # 76 | # Expire the session after this number of seconds of inactivity. If this 77 | # value is +nil+, the Session will never expire due to inactivity. 78 | # (but still may expire for example due to max_lifetime). 79 | # 80 | # Default: 1800 seconds (30 minutes) 81 | # 82 | attr_accessor :expire_after 83 | 84 | # 85 | # The maximum lifetime of this session in seconds. A value of +nil+ means 86 | # infinite lifetime. 87 | # 88 | # Default: nil (infinite lifetime) 89 | # 90 | attr_accessor :max_lifetime 91 | 92 | # 93 | # The maximum number of requests this session is allowed to serve. 94 | # A value of +nil+ means no limitation. 95 | # 96 | # Default: nil (infinite number of requests) 97 | # 98 | attr_accessor :max_requests 99 | 100 | # 101 | # Creates a new session. 102 | # 103 | def initialize(root_component, serializer=nil, page_cache_capacity=20) 104 | @root_component = root_component 105 | @page_cache = Wee::LRUCache.new(page_cache_capacity) 106 | @page_ids = Wee::IdGenerator::Sequential.new 107 | @current_page = nil 108 | 109 | @running = true 110 | 111 | @expire_after = 30*60 112 | @max_lifetime = nil 113 | @max_requests = nil 114 | 115 | @last_access = @creation_time = Time.now 116 | @request_count = 0 117 | 118 | @serializer = serializer || MutexSerializer.new 119 | end 120 | 121 | # 122 | # Terminates the session. 123 | # 124 | # This will usually not immediatly terminate the session from running, but 125 | # further requests will not be answered. 126 | # 127 | def terminate 128 | @running = false 129 | end 130 | 131 | # 132 | # Queries whether the session is still alive. 133 | # 134 | def alive? 135 | now = Time.now 136 | return false if not @running 137 | return false if @expire_after and now - @last_access > @expire_after 138 | return false if @max_lifetime and now - @creation_time > @max_lifetime 139 | return false if @max_requests and @request_count >= @max_requests 140 | return true 141 | end 142 | 143 | # 144 | # Queries whether the session is dead. 145 | # 146 | def dead? 147 | not alive? 148 | end 149 | 150 | # 151 | # Returns some statistics 152 | # 153 | def statistics 154 | now = Time.now 155 | { 156 | :last_access => @last_access, # The time when this session was last accessed 157 | :inactivity => now - @last_access, # The number of seconds of inactivity 158 | :creation_time => @creation_time, # The time at which this session was created 159 | :lifetime => now - @creation_time, # The lifetime of this session in seconds 160 | :request_count => @request_count # The number of requests served by this session 161 | } 162 | end 163 | 164 | # 165 | # Returns the current session (thread-local). 166 | # 167 | def self.current 168 | Thread.current[:wee_session] || (raise "Not in session") 169 | end 170 | 171 | # 172 | # Handles a web request. 173 | # 174 | def call(env) 175 | if env['wee.session'] 176 | # we are already serialized 177 | raise if env['wee.session'] != self 178 | begin 179 | Thread.current[:wee_session] = self 180 | @request_count += 1 181 | @last_access = Time.now 182 | awake 183 | response = handle(env) 184 | sleep 185 | return response 186 | ensure 187 | Thread.current[:wee_session] = nil 188 | end 189 | else 190 | env['wee.session'] = self 191 | @serializer.call(env) 192 | end 193 | end 194 | 195 | # 196 | # Send a premature response 197 | # 198 | def send_response(response) 199 | raise AbortProcessing.new(response) 200 | end 201 | 202 | protected 203 | 204 | # 205 | # Is called before process_request is invoked. 206 | # 207 | # Can be used e.g. to setup a database connection. 208 | # 209 | def awake 210 | end 211 | 212 | # 213 | # Is called after process_request is run. 214 | # 215 | # Can be used e.g. to release a database connection. 216 | # 217 | def sleep 218 | end 219 | 220 | # 221 | # The main routine where the request is processed. 222 | # 223 | def handle(env) 224 | request = Wee::Request.new(env) 225 | @request = request # CONTINUATIONS! 226 | page = @page_cache.fetch(request.page_id) 227 | 228 | if page 229 | if page != @current_page 230 | @current_page = nil 231 | page.state.restore 232 | @current_page = page 233 | end 234 | 235 | if request.render? 236 | return render(request, page).finish 237 | else # request.action? 238 | return action(request, page).finish 239 | end 240 | else 241 | # 242 | # either no or invalid page_id specified. reset to initial state (or 243 | # create initial state if no such exists yet) 244 | # 245 | @initial_state ||= take_snapshot() 246 | new_page = Page.new(@page_ids.next, @initial_state, nil) 247 | @page_cache.store(new_page.id, new_page) 248 | 249 | url = request.build_url(:page_id => new_page.id) 250 | if request.page_id 251 | return Wee::RefreshResponse.new("Invalid or expired page", url).finish 252 | else 253 | return Wee::RedirectResponse.new(url).finish 254 | end 255 | end 256 | ensure 257 | @request = nil 258 | end 259 | 260 | def render_ajax_proc(block, component) 261 | proc { 262 | r = component.renderer_class.new 263 | r.session = self 264 | r.request = @request 265 | r.response = Wee::Response.new 266 | r.document = Wee::HtmlDocument.new 267 | r.callbacks = @page.callbacks 268 | r.current_component = component 269 | 270 | begin 271 | block.call(r) 272 | ensure 273 | r.close 274 | end 275 | 276 | r.response << r.document.to_s 277 | send_response(r.response) 278 | } 279 | end 280 | 281 | public :render_ajax_proc 282 | 283 | def render(request, page) 284 | r = Wee::Renderer.new 285 | r.session = self 286 | r.request = request 287 | r.response = Wee::GenericResponse.new 288 | r.document = Wee::HtmlDocument.new 289 | r.callbacks = Wee::Callbacks.new 290 | 291 | begin 292 | @root_component.decoration.render!(r) 293 | r.close 294 | r.response << r.document.to_s 295 | rescue AbortProcessing => abort 296 | r.response = abort.response 297 | end 298 | 299 | page.callbacks = r.callbacks 300 | return r.response 301 | end 302 | 303 | def action(request, page) 304 | @current_page = nil 305 | 306 | begin 307 | @page = page # CONTINUATIONS! 308 | action_callback = page.callbacks.with_triggered(request.fields) do 309 | @root_component.decoration.process_callbacks(page.callbacks) 310 | end 311 | if action_callback 312 | action_callback.call 313 | elsif request.ajax? 314 | # 315 | # An action request with an action-id without a corresponding 316 | # registered action callback is considered an invalid request. 317 | # 318 | # This can happen for AJAX update requests and indicates that 319 | # two or more requests are send out too quickly; the first 320 | # succeed, renders and updates a div-tag and registers 321 | # a new callback (the old is unregistered). The following 322 | # request still uses the old callback id, but now the 323 | # callback id has already been unregistered by the previous 324 | # request. 325 | # 326 | return NotFoundResponse.new 327 | end 328 | rescue AbortProcessing => abort 329 | page = @page # CONTINUATIONS! 330 | if abort.response 331 | # 332 | # replace the state of the current page 333 | # 334 | @current_page = page 335 | page.state = take_snapshot() 336 | @page_cache.store(page.id, page) 337 | return abort.response 338 | else 339 | # pass on - this is a premature response from Component#call 340 | end 341 | end 342 | request = @request # CONTINUATIONS! 343 | 344 | # 345 | # create new page (state) 346 | # 347 | new_page = Page.new(@page_ids.next, take_snapshot(), nil) 348 | @page_cache.store(new_page.id, new_page) 349 | @current_page = new_page 350 | 351 | url = request.build_url(:page_id => new_page.id) 352 | return Wee::RedirectResponse.new(url) 353 | end 354 | 355 | # 356 | # This method takes a snapshot from the current state of the root component 357 | # and returns it. 358 | # 359 | def take_snapshot 360 | @root_component.decoration.state(s = Wee::State.new) 361 | return s.freeze 362 | end 363 | 364 | end # class Session 365 | 366 | end # module Wee 367 | -------------------------------------------------------------------------------- /lib/wee/state.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | 3 | # 4 | # This class is for backtracking the state of components (or 5 | # decorations/presenters). Components that want an undo-facility to be 6 | # implemented (triggered for example by a browsers back-button), have to 7 | # overwrite the Component#state method. Class Wee::State simply 8 | # represents a collection of objects from which snapshots were taken via 9 | # methods take_snapshot. 10 | # 11 | class State 12 | class Snapshot 13 | def initialize(object) 14 | @object = object 15 | @snapshot = nil 16 | @has_snapshot = false 17 | @ivars = nil 18 | end 19 | 20 | def take 21 | @snapshot = @object.take_snapshot unless @has_snapshot 22 | @has_snapshot = true 23 | end 24 | 25 | def add_ivar(ivar, value) 26 | @ivars ||= {} 27 | @ivars[ivar] = value 28 | end 29 | 30 | def restore 31 | @object.restore_snapshot(@snapshot) if @has_snapshot 32 | @ivars.each_pair {|k,v| @object.instance_variable_set(k, v) } if @ivars 33 | end 34 | end 35 | 36 | def initialize 37 | @snapshots = Hash.new 38 | end 39 | 40 | def add(object) 41 | (@snapshots[object.object_id] ||= Snapshot.new(object)).take 42 | end 43 | 44 | def add_ivar(object, ivar, value=object.instance_variable_get(ivar)) 45 | (@snapshots[object.object_id] ||= Snapshot.new(object)).add_ivar(ivar, value) 46 | end 47 | 48 | alias << add 49 | 50 | def restore 51 | @snapshots.each_value {|snapshot| snapshot.restore} 52 | end 53 | end # class State 54 | 55 | module DupReplaceSnapshotMixin 56 | def take_snapshot 57 | dup 58 | end 59 | 60 | def restore_snapshot(snap) 61 | replace(snap) 62 | end 63 | end # module DupReplaceSnapshotMixin 64 | 65 | module ObjectSnapshotMixin 66 | def take_snapshot 67 | snap = Hash.new 68 | instance_variables.each do |iv| 69 | snap[iv] = instance_variable_get(iv) 70 | end 71 | snap 72 | end 73 | 74 | def restore_snapshot(snap) 75 | snap.each do |k,v| 76 | instance_variable_set(k, v) 77 | end 78 | end 79 | end # module ObjectSnapshotMixin 80 | 81 | module StructSnapshotMixin 82 | def take_snapshot 83 | snap = Hash.new 84 | each_pair {|k,v| snap[k] = v} 85 | snap 86 | end 87 | 88 | def restore_snapshot(snap) 89 | snap.each_pair {|k,v| send(k.to_s + "=", v)} 90 | end 91 | end # module StructSnapshotMixin 92 | 93 | end # module Wee 94 | 95 | # 96 | # Extend base classes with snapshot functionality 97 | # 98 | class Object; include Wee::ObjectSnapshotMixin end 99 | class Struct; include Wee::StructSnapshotMixin end 100 | class Array; include Wee::DupReplaceSnapshotMixin end 101 | class String; include Wee::DupReplaceSnapshotMixin end 102 | class Hash; include Wee::DupReplaceSnapshotMixin end 103 | -------------------------------------------------------------------------------- /lib/wee/task.rb: -------------------------------------------------------------------------------- 1 | require 'wee/component' 2 | 3 | module Wee 4 | 5 | class Task < Component 6 | 7 | def go 8 | end 9 | 10 | def render(r) 11 | r.session.send_response(RedirectResponse.new(r.url_for_callback(method(:go)))) 12 | end 13 | 14 | end # class Task 15 | 16 | end # module Wee 17 | -------------------------------------------------------------------------------- /lib/wee/version.rb: -------------------------------------------------------------------------------- 1 | module Wee 2 | VERSION = "2.3.0" 3 | Version = VERSION 4 | end 5 | -------------------------------------------------------------------------------- /spec/component_spec.rb: -------------------------------------------------------------------------------- 1 | require 'wee/component' 2 | 3 | describe Wee::Component, "when first created" do 4 | before do 5 | @component = Wee::Component.new 6 | end 7 | 8 | it "should have no children" do 9 | expect(@component.children).to be_empty 10 | end 11 | 12 | it "should have no decoration" do 13 | expect(@component.decoration).to eq(@component) 14 | end 15 | end 16 | 17 | describe Wee::Component, "after adding one decoration" do 18 | before do 19 | @component = Wee::Component.new 20 | @decoration = Wee::Decoration.new 21 | @component.add_decoration(@decoration) 22 | end 23 | 24 | it "should point to the added decoration" do 25 | expect(@component.decoration).to eq(@decoration) 26 | end 27 | 28 | it "the added decoration should point back to the component" do 29 | expect(@component.decoration.next).to eq(@component) 30 | end 31 | 32 | it "should return decoration after removing it" do 33 | expect(@component.remove_decoration(@decoration)).to eq(@decoration) 34 | end 35 | 36 | it "should have no decoration after removing it" do 37 | @component.remove_decoration(@decoration) 38 | expect(@component.decoration).to eq(@component) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/bm_render.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Render Wee::HelloWorld n-times 3 | # 4 | 5 | $LOAD_PATH.unshift "./lib" 6 | require 'rubygems' 7 | require 'wee' 8 | require 'rack' 9 | 10 | class Rack::Request 11 | def put?; get? end 12 | end 13 | 14 | class Wee::HtmlWriter 15 | def join 16 | @port 17 | end 18 | end 19 | 20 | root_component = Wee::HelloWorld.new 21 | Integer(ARGV[0] || raise).times do 22 | r = Wee::Renderer.new 23 | r.request = Wee::Request.new({'REQUEST_METHOD' => 'GET', 'SCRIPT_NAME' => 'blah', 'PATH_INFO' => 'blubb', 24 | 'QUERY_STRING' => '_p=blah&_s=session'}) 25 | r.document = Wee::HtmlDocument.new 26 | r.callbacks = Wee::Callbacks.new 27 | 28 | begin 29 | root_component.decoration.render!(r) 30 | ensure 31 | r.close 32 | end 33 | Wee::GenericResponse.new(r.document.join) 34 | end 35 | -------------------------------------------------------------------------------- /test/stress/plotter.rb: -------------------------------------------------------------------------------- 1 | class GnuPlot 2 | def self.spawn 3 | new(IO.popen("gnuplot", "w+")) 4 | end 5 | 6 | def initialize(port) 7 | @port = port 8 | end 9 | 10 | def plot(datasets) 11 | @port << "plot" 12 | datasets.each_with_index do |h, i| 13 | @port << (i == 0 ? ' ' : ', ') 14 | @port << "'-' title '#{ h[:title] }' #{ h[:params] }" 15 | end 16 | @port << "\n" 17 | 18 | datasets.each do |h| 19 | @port << h[:data].map{|v| v.join(" ")}.join("\n") 20 | @port << "\ne\n" 21 | end 22 | 23 | self 24 | end 25 | 26 | def exit 27 | @port << "exit\n" 28 | @port.close 29 | @port = nil 30 | end 31 | end 32 | 33 | class GenericPlotter 34 | def initialize(interval, dataset_configs) 35 | @interval = interval 36 | @datasets = dataset_configs 37 | @datasets.each_with_index {|cfg, i| 38 | cfg[:params] ||= 'with lines' 39 | cfg[:title] ||= i.to_s 40 | cfg[:data] ||= [] 41 | } 42 | @gnuplot = GnuPlot.spawn 43 | end 44 | 45 | def run 46 | Thread.start { 47 | @time = 0 48 | loop do 49 | @datasets.each do |cfg| 50 | cfg[:proc].call(cfg[:data], @time) 51 | end 52 | @gnuplot.plot(@datasets) 53 | sleep @interval 54 | @time += @interval 55 | end 56 | } 57 | end 58 | end 59 | 60 | class ObjectPlotter < GenericPlotter 61 | def initialize(interval, *klasses) 62 | super(interval, klasses.map {|k| 63 | {:title => k.to_s,# :params, 'with linespoints', 64 | :proc => proc {|data, time| data << [time, ObjectSpace.each_object(k) {}] } } 65 | }) 66 | end 67 | end 68 | 69 | class MemoryPlotter < GenericPlotter 70 | def initialize(interval, *pids) 71 | super(interval, pids.map {|pid| 72 | {:title => "pid: #{ pid }", :proc => proc {|data, time| data << [time, measure_memory(pid)] } } 73 | }) 74 | end 75 | 76 | # return usage of process +pid+ in kb 77 | def measure_memory(pid=Process.pid) 78 | ["/proc/#{ pid }/status", "/compat/linux/proc/#{ pid }/status"].each {|file| 79 | return $1.to_i if File.exists?(file) and File.read(file) =~ /^VmSize:\s*(\d+)\s*kB$/ 80 | } 81 | mem, res = `ps -p #{ pid } -l`.split("\n").last.strip.split(/\s+/)[6..7] 82 | return mem.to_i 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/stress/stress_client.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'mechanize' 3 | 4 | class StressSession 5 | def initialize 6 | @agent = WWW::Mechanize.new {|a| 7 | a.max_history = 1 8 | } 9 | @agent.get('http://localhost:2000/') 10 | end 11 | 12 | def click(val) 13 | link = @agent.page.links.find {|l| l.node.text == val} 14 | @agent.click(link) 15 | end 16 | 17 | def submit(val) 18 | form = @agent.page.forms.first 19 | button = form.buttons.find {|b| b.value == val} 20 | @agent.submit(form, button) 21 | rescue 22 | puts "invalid" 23 | p @agent.page 24 | p form 25 | sleep 5 26 | end 27 | 28 | def step 29 | %w(OK Cancel).each {|b| 30 | click('show') 31 | submit('OK') 32 | submit(b) 33 | } 34 | [%w(OK OK), %w(OK Cancel), %w(Cancel OK), %w(Cancel Cancel)].each {|b1, b2| 35 | click('show') 36 | submit('Cancel') 37 | submit(b1) 38 | submit(b2) 39 | } 40 | end 41 | end 42 | 43 | if __FILE__ == $0 44 | num_sessions = Integer(ARGV[0] || raise) 45 | puts "num_sessions: #{num_sessions}" 46 | 47 | sessions = (1..num_sessions).map { StressSession.new } 48 | loop do 49 | sessions.each {|s| s.step } 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/stress/stress_local.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift "../../lib" 2 | require 'rubygems' 3 | require 'wee' 4 | require 'rack/mock' 5 | 6 | class HelloWorld < Wee::Component 7 | 8 | class Called2 < Wee::Component 9 | def render(r) 10 | r.anchor.callback { answer }.with('back') 11 | end 12 | end 13 | 14 | class Called1 < Wee::Component 15 | def render(r) 16 | r.anchor.callback { callcc Called2.new; answer }.with('back') 17 | end 18 | end 19 | 20 | def initialize 21 | add_decoration(Wee::PageDecoration.new("Hello World")) 22 | @counter = 0 23 | end 24 | 25 | def render(r) 26 | r.h1 "Hello World from Wee!" 27 | r.anchor.callback { callcc Called1.new }.with(@counter.to_s) 28 | end 29 | end 30 | 31 | class StressTest 32 | def initialize 33 | @app = Wee::Application.new { 34 | Wee::Session.new(HelloWorld.new, Wee::Session::ThreadSerializer.new) 35 | } 36 | end 37 | 38 | def request(uri) 39 | env = Rack::MockRequest.env_for(uri) 40 | resp = @app.call(env) 41 | if resp.first == 302 42 | request(resp[1]["Location"]) 43 | else 44 | resp.last.body.join 45 | end 46 | end 47 | 48 | def run(n=10_000, verbose=false) 49 | next_uri = '/' 50 | 51 | n.times do 52 | p next_uri if verbose 53 | body = request(next_uri) 54 | 55 | if body =~ /href="([^"]*)"/ 56 | next_uri = $1 57 | else 58 | raise 59 | end 60 | end 61 | end 62 | end 63 | 64 | if __FILE__ == $0 65 | if ARGV.size < 2 or ARGV.size > 3 66 | puts %{USAGE: #$0 num_threads num_iters ["verbose"]} 67 | exit 1 68 | end 69 | 70 | num_threads, num_iters, verbose = Integer(ARGV[0]), Integer(ARGV[1]), ARGV[2] == "verbose" 71 | 72 | if verbose 73 | $LOAD_PATH.unshift '.' 74 | require 'plotter' 75 | MemoryPlotter.new(5, Process.pid).run 76 | ObjectPlotter.new(5, Object, Array, String, Hash, Bignum).run 77 | ObjectPlotter.new(5, Thread, Continuation, Proc).run 78 | end 79 | 80 | app = StressTest.new 81 | (1..num_threads).map { 82 | Thread.new { app.run(num_iters, verbose) } 83 | }.each {|th| th.join} 84 | 85 | STDIN.readline if verbose 86 | end 87 | -------------------------------------------------------------------------------- /test/stress/stress_server.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift '../../lib' 2 | require 'wee' 3 | 4 | class Wee::MessageBox < Wee::Component 5 | def initialize(text) 6 | @text = text 7 | end 8 | 9 | def render(r) 10 | r.bold(@text) 11 | r.form do 12 | r.submit_button.value('OK').callback { answer true } 13 | r.space 14 | r.submit_button.value('Cancel').callback { answer false } 15 | end 16 | end 17 | end 18 | 19 | class CallTest < Wee::Component 20 | def msgbox(msg, state=nil) 21 | if state 22 | call Wee::MessageBox.new(msg), &method(state) 23 | else 24 | call Wee::MessageBox.new(msg), &method(state) 25 | end 26 | end 27 | 28 | def state1 29 | msgbox('A', :state2) 30 | end 31 | 32 | def state2(res) 33 | res ? msgbox('B') : msgbox('C', :state3) 34 | end 35 | 36 | def state3(res) 37 | msgbox('D') 38 | end 39 | 40 | def render(r) 41 | r.anchor.callback { state1 }.with("show") 42 | end 43 | end 44 | 45 | class CallTestCC < Wee::Component 46 | def msgbox(msg) 47 | callcc Wee::MessageBox.new(msg) 48 | end 49 | 50 | def render(r) 51 | r.anchor.callback { 52 | if msgbox('A') 53 | msgbox('B') 54 | else 55 | msgbox('C') 56 | msgbox('D') 57 | end 58 | }.with("show") 59 | end 60 | end 61 | 62 | if __FILE__ == $0 63 | $LOAD_PATH.unshift '.' 64 | require 'plotter' 65 | MemoryPlotter.new(5, Process.pid).run 66 | ObjectPlotter.new(5, Object, Array, String, Hash, Bignum).run 67 | ObjectPlotter.new(5, Thread, Continuation, Proc).run 68 | 69 | mode = ARGV[0] 70 | page_cache_capa = Integer(ARGV[1] || 20) 71 | 72 | puts "mode: #{mode}" 73 | puts "capa: #{page_cache_capa}" 74 | 75 | case mode 76 | when 'call' 77 | Wee.run { Wee::Session.new(CallTest.new, nil, page_cache_capa) } 78 | when 'callcc' 79 | Wee.run { Wee::Session.new(CallTestCC.new, Wee::Session::ThreadSerializer.new, page_cache_capa) } 80 | else 81 | raise 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/test_component.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'wee' 3 | 4 | class Test_Component < Test::Unit::TestCase 5 | def test_add_remove_one_decoration 6 | c = Wee::Component.new 7 | d = Wee::Decoration.new 8 | 9 | assert_same c, c.decoration 10 | assert_nil d.owner 11 | 12 | c.add_decoration(d) 13 | 14 | assert_same d, c.decoration 15 | assert_same c, d.owner 16 | assert_same d, d.owner.decoration 17 | 18 | assert_same d, c.remove_decoration(d) 19 | 20 | assert_same c, c.decoration 21 | assert_nil d.owner 22 | end 23 | 24 | def test_add_remove_multiple_decorations 25 | c = Wee::Component.new 26 | d1 = Wee::Decoration.new 27 | d2 = Wee::Decoration.new 28 | d3 = Wee::Decoration.new 29 | 30 | c.add_decoration(d1) 31 | c.add_decoration(d2) 32 | c.add_decoration(d3) 33 | 34 | assert_same d3, c.decoration 35 | assert_same d2, d3.owner 36 | assert_same d1, d2.owner 37 | assert_same c, d1.owner 38 | 39 | assert_same d2, c.remove_decoration(d2) 40 | 41 | assert_same d3, c.decoration 42 | assert_same d1, d3.owner 43 | assert_nil d2.owner 44 | assert_same c, d1.owner 45 | 46 | assert_same d1, c.remove_decoration(d1) 47 | assert_same d3, c.decoration 48 | assert_same c, d3.owner 49 | assert_nil d1.owner 50 | 51 | # try to remove an already removed decoration 52 | assert_nil c.remove_decoration(d2) 53 | assert_nil c.remove_decoration(d1) 54 | 55 | assert_same d3, c.remove_decoration(d3) 56 | assert_same c, c.decoration 57 | assert_nil d3.owner 58 | end 59 | 60 | def test_each_decoration 61 | require 'enumerator' 62 | c = Wee::Component.new 63 | d1 = Wee::Decoration.new 64 | d2 = Wee::Decoration.new 65 | d3 = Wee::Decoration.new 66 | 67 | c.add_decoration(d1) 68 | c.add_decoration(d2) 69 | c.add_decoration(d3) 70 | 71 | assert_equal [d3, d2, d1], c.to_enum(:each_decoration).to_a 72 | end 73 | 74 | def test_remove_decoration_if 75 | c = Wee::Component.new 76 | d1 = Wee::Decoration.new 77 | d2 = Wee::Decoration.new 78 | d3 = Wee::Decoration.new 79 | d4 = Wee::Decoration.new 80 | 81 | c.add_decoration(d1) 82 | c.add_decoration(d2) 83 | c.add_decoration(d3) 84 | c.add_decoration(d4) 85 | 86 | def d1.a() end 87 | def d3.a() end 88 | c.remove_decoration_if {|d| d.respond_to?(:a)} 89 | assert_equal [d4, d2], c.to_enum(:each_decoration).to_a 90 | 91 | def d4.a() end 92 | c.remove_decoration_if {|d| d.respond_to?(:a)} 93 | assert_equal [d2], c.to_enum(:each_decoration).to_a 94 | 95 | c.remove_decoration_if {|d| d.respond_to?(:a)} 96 | assert_equal [d2], c.to_enum(:each_decoration).to_a 97 | 98 | def d2.a() end 99 | c.remove_decoration_if {|d| d.respond_to?(:a)} 100 | assert_equal [], c.to_enum(:each_decoration).to_a 101 | 102 | assert_equal c, c.decoration 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/test_html_canvas.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'wee' 3 | 4 | class Test_HtmlCanvas < Test::Unit::TestCase 5 | def test_simple 6 | rctx = Wee::RenderingContext.new 7 | rctx.document = Wee::HtmlWriter.new(doc='') 8 | 9 | c = Wee::HtmlCanvas.new(rctx) 10 | c.form.action("foo").with { 11 | c.table { 12 | c.table_row.id("myrow").with { 13 | c.table_data.align_top.with("Hello world") 14 | } 15 | } 16 | c.space 17 | } 18 | 19 | assert_equal %[
Hello world
 ], doc 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/test_html_writer.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'wee' 3 | 4 | class Test_HtmlWriter < Test::Unit::TestCase 5 | def test_document 6 | w = Wee::HtmlWriter.new(doc='') 7 | w.start_tag('html') 8 | w.start_tag('body') 9 | w.start_tag('a', 'href' => 'http://...') 10 | w.text('link') 11 | w.end_tag('a') 12 | w.end_tag('body') 13 | w.end_tag('html') 14 | 15 | assert_equal 'link', doc 16 | end 17 | 18 | def test_start_end_tag 19 | w = Wee::HtmlWriter.new(doc='') 20 | w.start_tag('a', 'href' => '') 21 | w.end_tag('a') 22 | assert_equal '', doc 23 | end 24 | 25 | def test_single_tag 26 | w = Wee::HtmlWriter.new(doc='') 27 | w.single_tag('a', 'href' => '') 28 | assert_equal '', doc 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /test/test_lru_cache.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'wee/lru_cache' 3 | 4 | class I 5 | include Wee::LRUCache::Item 6 | 7 | attr_accessor :id 8 | 9 | def initialize(id) 10 | @id = id 11 | end 12 | end 13 | 14 | class Test_LRUCache < Test::Unit::TestCase 15 | def test_replacement 16 | cache = Wee::LRUCache.new(2) 17 | def cache.purge(item) @purged = item end 18 | def cache.purged() @purged end 19 | 20 | a = I.new("a") 21 | b = I.new("b") 22 | c = I.new("c") 23 | 24 | assert_nil cache.purged 25 | 26 | cache.store(a.id, a) 27 | assert_nil cache.purged 28 | 29 | cache.store(b.id, b) 30 | assert_nil cache.purged 31 | 32 | cache.store(c.id, c) 33 | assert_same a, cache.purged 34 | 35 | cache.store(a.id, a) 36 | assert_same b, cache.purged 37 | 38 | cache.store(b.id, b) 39 | assert_same c, cache.purged 40 | 41 | # 42 | # Reads also modify LRU 43 | # 44 | assert_same a, cache.fetch(a.id) 45 | assert_same b, cache.fetch(b.id) 46 | assert_same a, cache.fetch(a.id) 47 | 48 | cache.store(c.id, c) 49 | assert_same b, cache.purged 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/test_request.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'wee/request' 3 | 4 | class Test_Request < Test::Unit::TestCase 5 | def test_parse 6 | d = Wee::Request::DELIM 7 | req = Wee::Request.new('/app', "/app/info#{d}req_handler_id/page_id", nil, nil, nil) 8 | assert_equal 'info', req.info 9 | assert_equal 'req_handler_id', req.request_handler_id 10 | assert_equal 'page_id', req.page_id 11 | end 12 | 13 | def test_fields 14 | fields = { 15 | 'a' => 1, 16 | 'b' => 2, 17 | 'a.x' => 3, 18 | 'a.y' => 4, 19 | } 20 | 21 | parsed = { 22 | 'a' => {nil => 1, 'x' => 3, 'y' => 4}, 23 | 'b' => 2 24 | } 25 | 26 | req = Wee::Request.new('/app', "/app", nil, fields, nil) 27 | assert_equal parsed, req.fields 28 | end 29 | 30 | def test_build_url 31 | d = Wee::Request::DELIM 32 | req = Wee::Request.new('/app', "/app/info#{d}req_handler_id/page_id", nil, nil, nil) 33 | 34 | assert_equal "/app/info#{d}req_handler_id/page_id?c", req.build_url(:callback_id => 'c') 35 | 36 | assert_equal "/app/info#{d}a/b?c", req.build_url(:request_handler_id => 'a', :page_id => 'b', :callback_id => 'c') 37 | assert_equal "/app/info#{d}req_handler_id/b", req.build_url(:request_handler_id => 'req_handler_id', :page_id => 'b') 38 | 39 | assert_equal "/app/info", req.build_url(:request_handler_id => nil, :page_id => nil) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /wee.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/wee/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "wee" 5 | spec.version = Wee::VERSION 6 | spec.platform = Gem::Platform::RUBY 7 | spec.authors = ["Michael Neumann"] 8 | spec.email = ["mneumann@ntecs.de"] 9 | 10 | spec.summary = 'Wee is a framework for building highly dynamic web applications.' 11 | spec.description = <<~EOF 12 | Wee is a stateful component-orient web framework which supports 13 | continuations as well as multiple page-states, aka backtracking. 14 | It is largely inspired by Smalltalk's Seaside framework. 15 | EOF 16 | spec.homepage = "https://github.com/mneumann/wee" 17 | spec.license = "MIT" 18 | 19 | # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" 20 | 21 | spec.metadata["homepage_uri"] = spec.homepage 22 | spec.metadata["source_code_uri"] = "https://github.com/mneumann/wee" 23 | spec.metadata["changelog_uri"] = "https://raw.githubusercontent.com/mneumann/wee/master/CHANGELOG.rdoc" 24 | 25 | # Specify which files should be added to the gem when it is released. 26 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 27 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 28 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 32 | spec.require_path = "lib" 33 | 34 | spec.add_development_dependency "bundler" 35 | spec.add_development_dependency "rake" 36 | spec.add_development_dependency "test-unit" 37 | spec.add_development_dependency "rspec" 38 | 39 | spec.add_dependency('rack', '~> 2.0') 40 | spec.add_dependency('fast_gettext', '>= 0.4.17') 41 | end 42 | --------------------------------------------------------------------------------