clevic 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/clevic.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'clevic/table_view.rb'
2
+ require 'clevic/model_builder.rb'
3
+
4
+
@@ -0,0 +1,195 @@
1
+ require 'clevic/search_dialog.rb'
2
+ require 'clevic/ui/browser_ui.rb'
3
+
4
+ module Clevic
5
+
6
+ =begin rdoc
7
+ The main application class. Each model for display should have a self.ui method
8
+ which returns a Clevic::TableView instance, usually in conjunction with
9
+ a ModelBuilder.
10
+
11
+ Clevic::TableView.new( Entry, parent ).create_model.new( Entry, parent ).create_model
12
+ .
13
+ .
14
+ .
15
+ end
16
+
17
+ Model instances may also implement <tt>self.key_press_event( event, current_index, view )</tt>
18
+ and <tt>self.data_changed( top_left_index, bottom_right_index, view )</tt> methods so that
19
+ they can respond to editing events and do Neat Stuff.
20
+ =end
21
+ class Browser < Qt::Widget
22
+ slots *%w{dump() refresh_table() filter_by_current(bool) next_tab() previous_tab() current_changed(int)}
23
+
24
+ def initialize( main_window )
25
+ super( main_window )
26
+
27
+ # do menus and widgets
28
+ @layout = Ui::Browser.new
29
+ @layout.setup_ui( main_window )
30
+
31
+ # set icon. MUST come after call to setup_ui
32
+ icon_path = Pathname.new( __FILE__ ).parent + "ui/icon.png"
33
+ raise "icon.png not found" unless icon_path.file?
34
+ main_window.window_icon = Qt::Icon.new( icon_path.realpath.to_s )
35
+
36
+ # add the tables tab
37
+ @tables_tab = Qt::TabWidget.new( @layout.main_widget )
38
+ @layout.main_widget.layout.add_widget @tables_tab
39
+ @tables_tab.tab_bar.focus_policy = Qt::NoFocus
40
+
41
+ # connect slots
42
+ @layout.action_dump.connect SIGNAL( 'triggered()' ), &method( :dump )
43
+ @layout.action_refresh.connect SIGNAL( 'triggered()' ), &method( :refresh_table )
44
+ @layout.action_filter.connect SIGNAL( 'triggered(bool)' ), &method( :filter_by_current )
45
+ @layout.action_next.connect SIGNAL( 'triggered()' ), &method( :next_tab )
46
+ @layout.action_previous.connect SIGNAL( 'triggered()' ), &method( :previous_tab )
47
+ @layout.action_find.connect SIGNAL( 'triggered()' ), &method( :find )
48
+ @layout.action_find_next.connect SIGNAL( 'triggered()' ), &method( :find_next )
49
+ @layout.action_new_row.connect SIGNAL( 'triggered()' ), &method( :new_row )
50
+
51
+ tables_tab.connect SIGNAL( 'currentChanged(int)' ), &method( :current_changed )
52
+
53
+ # as an example
54
+ #~ tables_tab.connect SIGNAL( 'currentChanged(int)' ) { |index| puts "other current_changed: #{index}" }
55
+ end
56
+
57
+ # activated by Ctrl-D for debugging
58
+ def dump
59
+ puts "table_view.model: #{table_view.model.inspect}" if table_view.class == Clevic::TableView
60
+ end
61
+
62
+ # return the Clevic::TableView object in the currently displayed tab
63
+ def table_view
64
+ tables_tab.current_widget
65
+ end
66
+
67
+ def tables_tab
68
+ @tables_tab
69
+ end
70
+
71
+ # display a search dialog, and find the entered text
72
+ def find
73
+ @search_dialog ||= SearchDialog.new
74
+ result = @search_dialog.exec( table_view.current_index.gui_value )
75
+
76
+ override_cursor( Qt::BusyCursor ) do
77
+ case result
78
+ when Qt::Dialog::Accepted
79
+ search_for = @search_dialog.search_text
80
+ table_view.search( @search_dialog )
81
+ when Qt::Dialog::Rejected
82
+ puts "Don't search"
83
+ else
84
+ puts "unknown dialog code #{result}"
85
+ end
86
+ end
87
+ end
88
+
89
+ def find_next
90
+ if @search_dialog.nil?
91
+ @layout.statusbar.show_message( 'No previous find' )
92
+ else
93
+ override_cursor( Qt::BusyCursor ) do
94
+ save_from_start = @search_dialog.from_start?
95
+ @search_dialog.from_start = false
96
+ table_view.search( @search_dialog )
97
+ @search_dialog.from_start = save_from_start
98
+ end
99
+ end
100
+ end
101
+
102
+ # force a complete reload of the current tab's data
103
+ def refresh_table
104
+ override_cursor( Qt::BusyCursor ) do
105
+ table_view.model.reload_data
106
+ end
107
+ end
108
+
109
+ # toggle the filter, based on current selection.
110
+ def filter_by_current( bool_filter )
111
+ # TODO if there's no selection, use the current index instead
112
+ table_view.filter_by_indexes( table_view.selection_model.selected_indexes )
113
+
114
+ # set the checkbox in the menu item
115
+ @layout.action_filter.checked = table_view.filtered
116
+
117
+ # update the tab, so there's a visual indication of filtering
118
+ tab_title = table_view.filtered ? translate( '| ' + table_view.model_class.name.humanize ) : translate( table_view.model_class.name.humanize )
119
+ tables_tab.set_tab_text( tables_tab.current_index, tab_title )
120
+ end
121
+
122
+ # slot to handle Ctrl-Tab and move to next tab, or wrap around
123
+ def next_tab
124
+ tables_tab.current_index =
125
+ if tables_tab.current_index >= tables_tab.count - 1
126
+ 0
127
+ else
128
+ tables_tab.current_index + 1
129
+ end
130
+ end
131
+
132
+ # slot to handle Ctrl-Backtab and move to previous tab, or wrap around
133
+ def previous_tab
134
+ tables_tab.current_index =
135
+ if tables_tab.current_index <= 0
136
+ tables_tab.count - 1
137
+ else
138
+ tables_tab.current_index - 1
139
+ end
140
+ end
141
+
142
+ def new_row
143
+ table_view.model.add_new_item
144
+ end
145
+
146
+ # slot to handle the currentChanged signal from tables_tab, and
147
+ # set focus on the grid
148
+ def current_changed( current_tab_index )
149
+ tables_tab.current_widget.setFocus
150
+ @layout.action_filter.checked = table_view.filtered
151
+ end
152
+
153
+ # shortcut for the Qt translate call
154
+ def translate( st )
155
+ Qt::Application.translate("Browser", st, nil, Qt::Application::UnicodeUTF8)
156
+ end
157
+
158
+ # return the list of models in $options[:models] or find them
159
+ # as descendants of ActiveRecord::Base
160
+ def find_models( models = $options[:models] )
161
+ if models.nil? || models.empty?
162
+ models = []
163
+ ObjectSpace.each_object( Class ) {|x| models << x if x.superclass == ActiveRecord::Base }
164
+ models
165
+ else
166
+ models
167
+ end
168
+ end
169
+
170
+ # Create the tabs, each with a collection for a particular model class.
171
+ #
172
+ # models parameter can be an array of Model objects, in order of display.
173
+ # if models is nil, find_models is called
174
+ def open( *models )
175
+ models = $options[:models] if models.empty?
176
+
177
+ # Add all existing model objects as tabs, one each
178
+ find_models( models ).each do |model|
179
+ if model.respond_to?( :ui )
180
+ tab = model.ui( tables_tab )
181
+ tab.connect( SIGNAL( 'status_text(QString)' ) ) { |msg| @layout.statusbar.show_message( msg, 20000 ) }
182
+ else
183
+ raise "Can't build ui for #{model.name}. Provide a self.ui method."
184
+ end
185
+ tables_tab.add_tab( tab, translate( model.name.humanize ) )
186
+ end
187
+ end
188
+
189
+ # make sure all outstanding records are saved
190
+ def save_all
191
+ tables_tab.each {|x| x.save_row( x.current_index ) }
192
+ end
193
+ end
194
+
195
+ end
@@ -0,0 +1,281 @@
1
+ require 'rubygems'
2
+ require 'active_record'
3
+ require 'active_record/dirty.rb'
4
+ require 'bsearch'
5
+
6
+ require 'profiler'
7
+
8
+ =begin rdoc
9
+ Store the SQL order_by attributes with ascending and descending values
10
+ =end
11
+ class OrderAttribute
12
+ attr_reader :direction, :attribute
13
+
14
+ def initialize( model_class, sql_order_fragment )
15
+ @model_class = model_class
16
+ if sql_order_fragment =~ /.*\.(.*?) *asc/
17
+ @direction = :asc
18
+ @attribute = $1
19
+ elsif sql_order_fragment =~ /.*\.(.*?) *desc/
20
+ @direction = :desc
21
+ @attribute = $1
22
+ else
23
+ @direction = :asc
24
+ @attribute = sql_order_fragment
25
+ end
26
+ end
27
+
28
+ # return ORDER BY field name
29
+ def to_s
30
+ @string ||= attribute
31
+ end
32
+
33
+ def to_sym
34
+ @sym ||= attribute.to_sym
35
+ end
36
+
37
+ # return 'field ASC' or 'field DESC', depending
38
+ def to_sql
39
+ "#{@model_class.table_name}.#{attribute} #{direction.to_s}"
40
+ end
41
+
42
+ def reverse( direction )
43
+ case direction
44
+ when :asc; :desc
45
+ when :desc; :asc
46
+ else; raise "unknown direction #{direction}"
47
+ end
48
+ end
49
+
50
+ # return the opposite ASC or DESC from to_sql
51
+ def to_reverse_sql
52
+ "#{@model_class.table_name}.#{attribute} #{reverse(direction).to_s}"
53
+ end
54
+
55
+ end
56
+
57
+ =begin rdoc
58
+ Fetch rows from the db on demand, rather than all up front.
59
+
60
+ Being able to change the recordset on the fly and still find a previously
61
+ known entity in the set requires a defined ordering, so if no ordering
62
+ is specified, the primary key of the entity will be used.
63
+
64
+ It hasn't been tested with compound primary keys.
65
+ --
66
+ TODO drop rows when they haven't been accessed for a while
67
+
68
+ TODO how to handle a quickly-changing underlying table? invalidate cache
69
+ for each call?
70
+ =end
71
+ class CacheTable < Array
72
+ # the number of records loaded in one call to the db
73
+ attr_accessor :preload_count
74
+ attr_reader :options
75
+
76
+ def initialize( model_class, find_options = {} )
77
+ @preload_count = 20
78
+ # must be before sanitise_options
79
+ @model_class = model_class
80
+ # must be before anything that uses options
81
+ @options = sanitise_options( find_options )
82
+
83
+ # size the array and fill it with nils. They'll be filled
84
+ # in by the [] operator
85
+ @row_count = sql_count
86
+ super(@row_count)
87
+ end
88
+
89
+ # The count of the records according to the db, which may be different to
90
+ # the records in the cache
91
+ def sql_count
92
+ @model_class.count( :conditions => @options[:conditions] )
93
+ end
94
+
95
+ def order
96
+ @options[:order]
97
+ end
98
+
99
+ def reverse_order
100
+ @order_attributes.map{|x| x.to_reverse_sql}.join(',')
101
+ end
102
+
103
+ def quote_column( field_name )
104
+ @model_class.connection.quote_column_name( field_name )
105
+ end
106
+
107
+ # recursively create a case statement to do the comparison
108
+ # because and ... and ... and filters on *each* one rather than
109
+ # consecutively.
110
+ # operator is either '<' or '>'
111
+ def build_recursive_comparison( operator, index = 0 )
112
+ # end recursion
113
+ return 'false' if index == @order_attributes.size
114
+
115
+ # fetch the current attribute
116
+ attribute = @order_attributes[index]
117
+
118
+ # build case statement, including recusion
119
+ st = <<-EOF
120
+ case
121
+ when #{quote_column attribute} #{operator} :#{attribute} then true
122
+ when #{quote_column attribute} = :#{attribute} then #{build_recursive_comparison( operator, index+1 )}
123
+ else false
124
+ end
125
+ EOF
126
+ end
127
+
128
+ # return a Hash containing
129
+ # :sql => the sql statement to be used as part of a where clause
130
+ # :params => the parameters corresponding to :sql
131
+ # entity is an AR model object
132
+ # direction is either :forwards or :backwards
133
+ def build_sql_find( entity, direction )
134
+ operator =
135
+ case direction
136
+ when :forwards; '>'
137
+ when :backwards; '<'
138
+ else; raise "unknown direction #{direction.inspect}"
139
+ end
140
+
141
+ # build the sql comparison where clause fragment
142
+ sql = build_recursive_comparison( operator )
143
+
144
+ # build parameter values
145
+ params = {}
146
+ @order_attributes.each {|x| params[x.to_sym] = entity.send( x.attribute )}
147
+ { :sql => sql, :params => params }
148
+ end
149
+
150
+ # add an id to options[:order] if it's not in there
151
+ # also create @order_attributes
152
+ def sanitise_options( options )
153
+ options[:order] ||= ''
154
+ @order_attributes = options[:order].split( /, */ ).map{|x| OrderAttribute.new(@model_class, x)}
155
+
156
+ # add the primary key if nothing is specified
157
+ # because we need an ordering of some kind otherwise
158
+ # index_for_entity will not work
159
+ if !@order_attributes.any? {|x| x.attribute == @model_class.primary_key }
160
+ @order_attributes << OrderAttribute.new( @model_class, @model_class.primary_key )
161
+ end
162
+
163
+ # recreate the options[:order] entry
164
+ options[:order] = @order_attributes.map{|x| x.to_sql}.join(',')
165
+
166
+ # give back the sanitised options
167
+ options
168
+ end
169
+
170
+ # Execute the block with the specified preload_count,
171
+ # and restore the existing one when done.
172
+ # Return the value of the block
173
+ def preload_limit( limit, &block )
174
+ old_limit = preload_count
175
+ self.preload_count = limit
176
+ retval = yield
177
+ self.preload_count = old_limit
178
+ retval
179
+ end
180
+
181
+ # Fetch the entity for the given index from the db, and store it
182
+ # in the array. Also, preload preload_count records to avoid subsequent
183
+ # hits on the db
184
+ def fetch_entity( index )
185
+ # calculate negative indices for the SQL offset
186
+ offset = index < 0 ? index + @row_count : index
187
+
188
+ # fetch self.preload_count records
189
+ records = @model_class.find( :all, @options.merge( :offset => offset, :limit => preload_count ) )
190
+ records.each_with_index {|x,i| self[i+index] = x if !cached_at?( i+index )}
191
+
192
+ # return the first one
193
+ records[0]
194
+ end
195
+
196
+ # return the entity at the given index. Fetch it from the
197
+ # db if it isn't in this array yet
198
+ def []( index )
199
+ super( index ) || fetch_entity( index )
200
+ end
201
+
202
+ # make a new instance that has the attributes of this one, but an empty
203
+ # data set. pass in ActiveRecord options to filter
204
+ def renew( options = {} )
205
+ clear
206
+ self.class.new( @model_class, @options.merge( options ) )
207
+ end
208
+
209
+ # Return the set of OrderAttribute objects for this collection
210
+ def order_attributes
211
+ # This is sorted in @options[:order], so use that for the search
212
+ if @order_attributes.nil?
213
+ @order_attributes = @options[:order].to_s.split( /, */ ).map{|x| OrderAttribute.new(@model_class, x)}
214
+
215
+ # add the primary key if nothing is specified
216
+ # because we need an ordering of some kind otherwise
217
+ # index_for_entity will not work
218
+ if !@order_attributes.any? {|x| x.attribute == @model_class.primary_key }
219
+ @order_attributes << OrderAttribute.new( @model_class, @model_class.primary_key )
220
+ end
221
+ end
222
+ @order_attributes
223
+ end
224
+
225
+ # find the index for the given entity, using a binary search algorithm (bsearch).
226
+ # The order_by ActiveRecord style options are used to do the binary search.
227
+ # 0 is returned if the entity is nil
228
+ # nil is returned if the array is empty
229
+ def index_for_entity( entity )
230
+ return nil if size == 0
231
+ return nil if entity.nil?
232
+
233
+ # only load one record at a time
234
+ preload_limit( 1 ) do
235
+ #~ puts "entity: #{entity.inspect}"
236
+ # do the binary search based on what we know about the search order
237
+ bsearch do |candidate|
238
+ #~ puts "candidate: #{candidate.inspect}"
239
+ # find using all sort attributes
240
+ order_attributes.inject(0) do |result,attribute|
241
+ if result == 0
242
+ method = attribute.attribute.to_sym
243
+ # compare taking ordering direction into account
244
+ retval =
245
+ if attribute.direction == :asc
246
+ #~ candidate.send( method ) <=> entity.send( method )
247
+ candidate[method] <=> entity[method]
248
+ else
249
+ #~ entity.send( method ) <=> candidate.send( method )
250
+ entity[method] <=> candidate[method]
251
+ end
252
+ # exit now because we have a difference
253
+ next( retval ) if retval != 0
254
+
255
+ # otherwise try with the next order attribute
256
+ retval
257
+ else
258
+ # they're equal, so try next order attribute
259
+ result
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+
266
+ def delete_at( index )
267
+ retval = super
268
+ if self.size == 0
269
+ self << @model_class.new
270
+ end
271
+ retval
272
+ end
273
+
274
+ end
275
+
276
+ class Array
277
+ # For use with CacheTable. Return true if something is cached, false otherwise
278
+ def cached_at?( index )
279
+ !at(index).nil?
280
+ end
281
+ end