clevic 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +371 -0
- data/INSTALL +10 -0
- data/Manifest.txt +30 -0
- data/README.txt +94 -0
- data/Rakefile +100 -0
- data/TODO +131 -0
- data/accounts_models.rb +122 -0
- data/bin/clevic +64 -0
- data/lib/active_record/dirty.rb +87 -0
- data/lib/clevic.rb +4 -0
- data/lib/clevic/browser.rb +195 -0
- data/lib/clevic/cache_table.rb +281 -0
- data/lib/clevic/db_options.rb +21 -0
- data/lib/clevic/delegates.rb +383 -0
- data/lib/clevic/extensions.rb +133 -0
- data/lib/clevic/field.rb +181 -0
- data/lib/clevic/item_delegate.rb +62 -0
- data/lib/clevic/model_builder.rb +171 -0
- data/lib/clevic/model_column.rb +23 -0
- data/lib/clevic/search_dialog.rb +77 -0
- data/lib/clevic/table_model.rb +431 -0
- data/lib/clevic/table_view.rb +479 -0
- data/lib/clevic/ui/browser.ui +201 -0
- data/lib/clevic/ui/browser_ui.rb +176 -0
- data/lib/clevic/ui/icon.png +0 -0
- data/lib/clevic/ui/search_dialog.ui +216 -0
- data/lib/clevic/ui/search_dialog_ui.rb +106 -0
- data/sql/accounts.sql +302 -0
- data/sql/times.sql +197 -0
- data/times_models.rb +163 -0
- metadata +93 -0
@@ -0,0 +1,431 @@
|
|
1
|
+
require 'Qt4'
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
require 'qtext/flags.rb'
|
5
|
+
require 'qtext/extensions.rb'
|
6
|
+
|
7
|
+
require 'clevic/extensions.rb'
|
8
|
+
require 'clevic/model_column'
|
9
|
+
|
10
|
+
module Clevic
|
11
|
+
|
12
|
+
=begin rdoc
|
13
|
+
This table model allows an ActiveRecord or ActiveResource to be used as a
|
14
|
+
basis for a Qt::AbstractTableModel for viewing in a Qt::TableView.
|
15
|
+
|
16
|
+
Initial idea by Richard Dale and Silvio Fonseca.
|
17
|
+
|
18
|
+
* labels are the headings in the table view
|
19
|
+
|
20
|
+
* dots are the dotted attribute paths that specify how to get values from
|
21
|
+
the underlying ActiveRecord model
|
22
|
+
|
23
|
+
* attribute_paths is a collection of attribute symbols. It comes from
|
24
|
+
dots, and is split on /\./
|
25
|
+
|
26
|
+
* attributes are the first-level of the dots
|
27
|
+
|
28
|
+
* collection is the set of ActiveRecord model objects (also called entities)
|
29
|
+
=end
|
30
|
+
class TableModel < Qt::AbstractTableModel
|
31
|
+
include QtFlags
|
32
|
+
|
33
|
+
attr_accessor :collection, :dots, :attributes, :attribute_paths, :labels
|
34
|
+
|
35
|
+
signals(
|
36
|
+
# index where error occurred, value, message
|
37
|
+
'data_error(QModelIndex,QVariant,QString)',
|
38
|
+
# top_left, bottom_right
|
39
|
+
'dataChanged(const QModelIndex&,const QModelIndex&)'
|
40
|
+
)
|
41
|
+
|
42
|
+
def initialize( builder )
|
43
|
+
super()
|
44
|
+
@metadatas = []
|
45
|
+
@builder = builder
|
46
|
+
end
|
47
|
+
|
48
|
+
def hasChildren( *args )
|
49
|
+
puts 'hasChildren'
|
50
|
+
puts "args: #{args.inspect}"
|
51
|
+
super
|
52
|
+
end
|
53
|
+
|
54
|
+
def sort( col, order )
|
55
|
+
puts 'sort'
|
56
|
+
puts "col: #{col.inspect}"
|
57
|
+
#~ Qt::AscendingOrder
|
58
|
+
#~ Qt::DescendingOrder
|
59
|
+
puts "order: #{order.inspect}"
|
60
|
+
super
|
61
|
+
end
|
62
|
+
|
63
|
+
def match( start_index, role, search_value, hits, match_flags )
|
64
|
+
#~ Qt::MatchExactly 0 Performs QVariant-based matching.
|
65
|
+
#~ Qt::MatchFixedString 8 Performs string-based matching. String-based comparisons are case-insensitive unless the MatchCaseSensitive flag is also specified.
|
66
|
+
#~ Qt::MatchContains 1 The search term is contained in the item.
|
67
|
+
#~ Qt::MatchStartsWith 2 The search term matches the start of the item.
|
68
|
+
#~ Qt::MatchEndsWith 3 The search term matches the end of the item.
|
69
|
+
#~ Qt::MatchCaseSensitive 16 The search is case sensitive.
|
70
|
+
#~ Qt::MatchRegExp 4 Performs string-based matching using a regular expression as the search term.
|
71
|
+
#~ Qt::MatchWildcard 5 Performs string-based matching using a string with wildcards as the search term.
|
72
|
+
#~ Qt::MatchWrap 32 Perform a search that wraps around, so that when the search reaches the last item in the model, it begins again at the first item and continues until all items have been examined.
|
73
|
+
super
|
74
|
+
end
|
75
|
+
|
76
|
+
def build_dots( dots, attrs, prefix="" )
|
77
|
+
attrs.inject( dots ) do |cols, a|
|
78
|
+
if a[1].respond_to? :attributes
|
79
|
+
build_keys(cols, a[1].attributes, prefix + a[0] + ".")
|
80
|
+
else
|
81
|
+
cols << prefix + a[0]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def model_class
|
87
|
+
@builder.model_class
|
88
|
+
end
|
89
|
+
|
90
|
+
# cache metadata (ActiveRecord#column_for_attribute) because it's not going
|
91
|
+
# to change over the lifetime of the table
|
92
|
+
# if the column is an attribute, create a ModelColumn
|
93
|
+
# TODO use ActiveRecord::Base.reflections instead
|
94
|
+
def metadata( column )
|
95
|
+
if @metadatas[column].nil?
|
96
|
+
meta = model_class.columns_hash[attributes[column].to_s]
|
97
|
+
if meta.nil?
|
98
|
+
meta = model_class.columns_hash[ "#{attributes[column]}_id" ]
|
99
|
+
if meta.nil?
|
100
|
+
return nil
|
101
|
+
else
|
102
|
+
@metadatas[column] = ModelColumn.new( attributes[column], :association, meta )
|
103
|
+
end
|
104
|
+
else
|
105
|
+
@metadatas[column] = meta
|
106
|
+
end
|
107
|
+
end
|
108
|
+
@metadatas[column]
|
109
|
+
end
|
110
|
+
|
111
|
+
def add_new_item
|
112
|
+
# 1 new row
|
113
|
+
begin_insert_rows( Qt::ModelIndex.invalid, row_count, row_count )
|
114
|
+
collection << model_class.new
|
115
|
+
end_insert_rows
|
116
|
+
end
|
117
|
+
|
118
|
+
# rows is a collection of integers specifying row indices to remove
|
119
|
+
# TODO call begin_remove and end_remove around the whole block
|
120
|
+
def remove_rows( rows )
|
121
|
+
# delete from the end to avoid holes affecting the indexing
|
122
|
+
rows.sort.reverse.each do |index|
|
123
|
+
# remove the item from the collection
|
124
|
+
begin_remove_rows( Qt::ModelIndex.invalid, index, index )
|
125
|
+
removed = collection.delete_at( index )
|
126
|
+
end_remove_rows
|
127
|
+
# destroy the db object, and its table row
|
128
|
+
removed.destroy
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# save the AR model at the given index, if it's dirty
|
133
|
+
def save( index )
|
134
|
+
item = collection[index.row]
|
135
|
+
return false if item.nil?
|
136
|
+
if item.changed?
|
137
|
+
if item.valid?
|
138
|
+
item.save
|
139
|
+
else
|
140
|
+
false
|
141
|
+
end
|
142
|
+
else
|
143
|
+
# AR model not changed
|
144
|
+
true
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def rowCount( parent = nil )
|
149
|
+
collection.size
|
150
|
+
end
|
151
|
+
|
152
|
+
def row_count
|
153
|
+
collection.size
|
154
|
+
end
|
155
|
+
|
156
|
+
def columnCount( parent = nil )
|
157
|
+
dots.size
|
158
|
+
end
|
159
|
+
|
160
|
+
def column_count
|
161
|
+
dots.size
|
162
|
+
end
|
163
|
+
|
164
|
+
def flags( model_index )
|
165
|
+
# TODO don't return IsEditable if the model is read-only
|
166
|
+
retval = qt_item_is_editable | super( model_index )
|
167
|
+
if model_index.metadata.type == :boolean
|
168
|
+
retval = item_boolean_flags
|
169
|
+
end
|
170
|
+
retval
|
171
|
+
end
|
172
|
+
|
173
|
+
def fetchMore( parent )
|
174
|
+
#~ puts "fetchMore"
|
175
|
+
#~ reload_data if canFetchMore( parent )
|
176
|
+
end
|
177
|
+
|
178
|
+
def canFetchMore( parent )
|
179
|
+
false
|
180
|
+
#~ puts "canFetchMore"
|
181
|
+
#~ puts "self.collection.size: #{self.collection.size.inspect}"
|
182
|
+
#~ puts "self.collection.sql_count: #{self.collection.sql_count.inspect}"
|
183
|
+
# Here, test for self.collection.size - new_records != self.collection.sql_count
|
184
|
+
# maintaining new_records will be the tricky part
|
185
|
+
#~ result = self.collection.size != self.collection.sql_count
|
186
|
+
#~ puts "result: #{result.inspect}"
|
187
|
+
#~ result
|
188
|
+
end
|
189
|
+
|
190
|
+
def reload_data( options = {} )
|
191
|
+
# renew cache
|
192
|
+
self.collection = self.collection.renew( options )
|
193
|
+
# tell the UI we had a major data change
|
194
|
+
reset
|
195
|
+
end
|
196
|
+
|
197
|
+
# values for horizontal and vertical headers
|
198
|
+
def headerData( section, orientation, role )
|
199
|
+
value =
|
200
|
+
case role
|
201
|
+
when qt_display_role
|
202
|
+
case orientation
|
203
|
+
when Qt::Horizontal
|
204
|
+
@labels[section]
|
205
|
+
when Qt::Vertical
|
206
|
+
# don't force a fetch from the db
|
207
|
+
if collection.cached_at?( section )
|
208
|
+
collection[section].id
|
209
|
+
else
|
210
|
+
section
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
when qt_text_alignment_role
|
215
|
+
case orientation
|
216
|
+
when Qt::Vertical
|
217
|
+
Qt::AlignRight | Qt::AlignVCenter
|
218
|
+
end
|
219
|
+
|
220
|
+
when Qt::SizeHintRole
|
221
|
+
# anything other than nil here makes the headers disappear.
|
222
|
+
nil
|
223
|
+
|
224
|
+
when qt_tooltip_role
|
225
|
+
if orientation == Qt::Horizontal
|
226
|
+
@builder.fields[section].tooltip
|
227
|
+
end
|
228
|
+
|
229
|
+
else
|
230
|
+
#~ puts "headerData section: #{section}, role: #{const_as_string(role)}" if $options[:debug]
|
231
|
+
nil
|
232
|
+
end
|
233
|
+
|
234
|
+
return value.to_variant
|
235
|
+
end
|
236
|
+
|
237
|
+
# Provide data to UI.
|
238
|
+
def data( index, role = qt_display_role )
|
239
|
+
#~ puts "data for index: #{index.inspect} and role: #{const_as_string role}"
|
240
|
+
begin
|
241
|
+
retval =
|
242
|
+
case role
|
243
|
+
when qt_display_role, qt_edit_role
|
244
|
+
# boolean values generally don't have text next to them in this context
|
245
|
+
# check explicitly to avoid fetching the entity from
|
246
|
+
# the model's collection when we don't need to
|
247
|
+
unless index.metadata.type == :boolean
|
248
|
+
begin
|
249
|
+
value = index.gui_value
|
250
|
+
unless value.nil?
|
251
|
+
field = @builder.fields[index.column]
|
252
|
+
field.do_format( value )
|
253
|
+
end
|
254
|
+
rescue Exception => e
|
255
|
+
puts e.backtrace
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
when qt_checkstate_role
|
260
|
+
if index.metadata.type == :boolean
|
261
|
+
index.gui_value ? qt_checked : qt_unchecked
|
262
|
+
end
|
263
|
+
|
264
|
+
when qt_text_alignment_role
|
265
|
+
@builder.fields[index.column].alignment
|
266
|
+
|
267
|
+
# these are just here to make debug output quieter
|
268
|
+
when qt_size_hint_role;
|
269
|
+
when qt_background_role;
|
270
|
+
when qt_font_role;
|
271
|
+
when qt_foreground_role;
|
272
|
+
when qt_decoration_role;
|
273
|
+
|
274
|
+
# provide a tooltip when an empty relational field is encountered
|
275
|
+
when qt_tooltip_role
|
276
|
+
if index.metadata.type == :association
|
277
|
+
@builder.fields[index.column].delegate.if_empty_message
|
278
|
+
end
|
279
|
+
|
280
|
+
else
|
281
|
+
puts "data index: #{index}, role: #{const_as_string(role)}" if $options[:debug]
|
282
|
+
nil
|
283
|
+
end
|
284
|
+
|
285
|
+
# return a variant
|
286
|
+
retval.to_variant
|
287
|
+
rescue Exception => e
|
288
|
+
puts e.backtrace.join( "\n" )
|
289
|
+
puts "#{index.inspect} #{value.inspect} #{index.entity.inspect} #{e.message}"
|
290
|
+
nil.to_variant
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
# data sent from UI
|
295
|
+
def setData( index, variant, role = qt_edit_role )
|
296
|
+
if index.valid?
|
297
|
+
case role
|
298
|
+
when qt_edit_role
|
299
|
+
# Don't allow the primary key to be changed
|
300
|
+
return false if index.attribute == :id
|
301
|
+
|
302
|
+
if ( index.column < 0 || index.column >= dots.size )
|
303
|
+
raise "invalid column #{index.column}"
|
304
|
+
end
|
305
|
+
|
306
|
+
type = index.metadata.type
|
307
|
+
value = variant.value
|
308
|
+
|
309
|
+
# translate the value from the ui to something that
|
310
|
+
# the AR model will understand
|
311
|
+
begin
|
312
|
+
index.gui_value =
|
313
|
+
case
|
314
|
+
when value.class.name == 'Qt::Date'
|
315
|
+
Date.new( value.year, value.month, value.day )
|
316
|
+
|
317
|
+
when value.class.name == 'Qt::Time'
|
318
|
+
Time.new( value.hour, value.min, value.sec )
|
319
|
+
|
320
|
+
# allow flexibility in entering dates. For example
|
321
|
+
# 16jun, 16-jun, 16 jun, 16 jun 2007 would be accepted here
|
322
|
+
# TODO need to be cleverer about which year to use
|
323
|
+
# for when you're entering 16dec and you're in the next
|
324
|
+
# year
|
325
|
+
when type == :date && value =~ %r{^(\d{1,2})[ /-]?(\w{3})$}
|
326
|
+
Date.parse( "#$1 #$2 #{Time.now.year.to_s}" )
|
327
|
+
|
328
|
+
# if a digit only is entered, fetch month and year from
|
329
|
+
# previous row
|
330
|
+
when type == :date && value =~ %r{^(\d{1,2})$}
|
331
|
+
previous_entity = collection[index.row - 1]
|
332
|
+
# year,month,day
|
333
|
+
Date.new( previous_entity.date.year, previous_entity.date.month, $1.to_i )
|
334
|
+
|
335
|
+
# this one is mostly to fix date strings that have come
|
336
|
+
# out of the db and been formatted
|
337
|
+
when type == :date && value =~ %r{^(\d{2})[ /-](\w{3})[ /-](\d{2})$}
|
338
|
+
Date.parse( "#$1 #$2 20#$3" )
|
339
|
+
|
340
|
+
# allow lots of flexibility in entering times
|
341
|
+
# 01:17, 0117, 117, 1 17, are all accepted
|
342
|
+
when type == :time && value =~ %r{^(\d{1,2}).?(\d{2})$}
|
343
|
+
Time.parse( "#$1:#$2" )
|
344
|
+
|
345
|
+
else
|
346
|
+
value
|
347
|
+
end
|
348
|
+
|
349
|
+
emit dataChanged( index, index )
|
350
|
+
# value conversion was successful
|
351
|
+
true
|
352
|
+
rescue Exception => e
|
353
|
+
puts e.backtrace.join( "\n" )
|
354
|
+
puts e.message
|
355
|
+
emit data_error( index, variant, e.message )
|
356
|
+
# value conversion was not successful
|
357
|
+
false
|
358
|
+
end
|
359
|
+
|
360
|
+
when qt_checkstate_role
|
361
|
+
if index.metadata.type == :boolean
|
362
|
+
index.entity.toggle!( index.attribute )
|
363
|
+
true
|
364
|
+
else
|
365
|
+
false
|
366
|
+
end
|
367
|
+
|
368
|
+
# user-defined role
|
369
|
+
# TODO this only works with single-dotted paths
|
370
|
+
when qt_paste_role
|
371
|
+
if index.metadata.type == :association
|
372
|
+
field = @builder.fields[index.column]
|
373
|
+
association_class = field.class_name.constantize
|
374
|
+
candidates = association_class.find( :all, :conditions => [ "#{field.attribute_path[1]} = ?", variant.value ] )
|
375
|
+
case candidates.size
|
376
|
+
when 0; puts "No match for #{variant.value}"
|
377
|
+
when 1; index.attribute_value = candidates[0]
|
378
|
+
else; puts "Too many for #{variant.value}"
|
379
|
+
end
|
380
|
+
else
|
381
|
+
index.attribute_value = variant.value
|
382
|
+
end
|
383
|
+
true
|
384
|
+
|
385
|
+
else
|
386
|
+
puts "role: #{role.inspect}"
|
387
|
+
true
|
388
|
+
|
389
|
+
end
|
390
|
+
else
|
391
|
+
false
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
# return a set of indexes that match the search criteria
|
396
|
+
def search( start_index, search_criteria )
|
397
|
+
# get the search value parameter, in SQL format
|
398
|
+
search_value =
|
399
|
+
if search_criteria.whole_words?
|
400
|
+
"% #{search_criteria.search_text} %"
|
401
|
+
else
|
402
|
+
"%#{search_criteria.search_text}%"
|
403
|
+
end
|
404
|
+
|
405
|
+
# build up the conditions
|
406
|
+
bits = collection.build_sql_find( start_index.entity, search_criteria.direction )
|
407
|
+
conditions = "#{model_class.connection.quote_column_name( start_index.field_name )} ilike :search_value"
|
408
|
+
conditions += ( " and " + bits[:sql] ) unless search_criteria.from_start?
|
409
|
+
params = { :search_value => search_value }
|
410
|
+
params.merge!( bits[:params] ) unless search_criteria.from_start?
|
411
|
+
#~ puts "conditions: #{conditions.inspect}"
|
412
|
+
#~ puts "params: #{params.inspect}"
|
413
|
+
# find the first match
|
414
|
+
entity = model_class.find(
|
415
|
+
:first,
|
416
|
+
:conditions => [ conditions, params ],
|
417
|
+
:order => search_criteria.direction == :forwards ? collection.order : collection.reverse_order
|
418
|
+
)
|
419
|
+
|
420
|
+
# return matched indexes
|
421
|
+
if entity != nil
|
422
|
+
found_row = collection.index_for_entity( entity )
|
423
|
+
[ create_index( found_row, start_index.column ) ]
|
424
|
+
else
|
425
|
+
[]
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
end
|
430
|
+
|
431
|
+
end #module
|
@@ -0,0 +1,479 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'Qt4'
|
3
|
+
require 'fastercsv'
|
4
|
+
require 'clevic/model_builder.rb'
|
5
|
+
|
6
|
+
module Clevic
|
7
|
+
|
8
|
+
# The view class, implementing neat shortcuts and other pleasantness
|
9
|
+
class TableView < Qt::TableView
|
10
|
+
attr_reader :model_class, :builder
|
11
|
+
# whether the model is currently filtered
|
12
|
+
# TODO better in QAbstractSortFilter?
|
13
|
+
attr_accessor :filtered
|
14
|
+
|
15
|
+
# this is emitted when this object was to display something in the status bar
|
16
|
+
signals 'status_text(QString)'
|
17
|
+
|
18
|
+
def initialize( model_class, parent, *args )
|
19
|
+
super( parent )
|
20
|
+
|
21
|
+
# the AR entity class
|
22
|
+
@model_class = model_class
|
23
|
+
|
24
|
+
# see closeEditor
|
25
|
+
@index_override = false
|
26
|
+
|
27
|
+
# set some Qt things
|
28
|
+
self.horizontal_header.movable = false
|
29
|
+
# TODO might be useful to allow movable vertical rows,
|
30
|
+
# but need to change the shortcut ideas of next and previous rows
|
31
|
+
self.vertical_header.movable = false
|
32
|
+
self.sorting_enabled = false
|
33
|
+
@filtered = false
|
34
|
+
|
35
|
+
# turn off "Object#type deprecated" messages
|
36
|
+
$VERBOSE = nil
|
37
|
+
end
|
38
|
+
|
39
|
+
def create_model( &block )
|
40
|
+
raise "provide a block" unless block
|
41
|
+
@builder = Clevic::ModelBuilder.new( self )
|
42
|
+
@builder.instance_eval( &block )
|
43
|
+
@builder.build
|
44
|
+
model.connect SIGNAL( 'dataChanged ( const QModelIndex &, const QModelIndex & )' ) do |top_left, bottom_right|
|
45
|
+
if @model_class.respond_to?( :data_changed )
|
46
|
+
@model_class.data_changed( top_left, bottom_right, self )
|
47
|
+
end
|
48
|
+
end
|
49
|
+
self
|
50
|
+
end
|
51
|
+
|
52
|
+
# alternative access for auto_size_column
|
53
|
+
def auto_size_attribute( attribute, sample )
|
54
|
+
col = model.attributes.index( attribute )
|
55
|
+
self.set_column_width( col, column_size( col, sample ).width )
|
56
|
+
end
|
57
|
+
|
58
|
+
# set the size of the column from the sample
|
59
|
+
def auto_size_column( col, sample )
|
60
|
+
self.set_column_width( col, column_size( col, sample ).width )
|
61
|
+
end
|
62
|
+
|
63
|
+
# set the size of the column from the string value of the data
|
64
|
+
# mostly copied from qheaderview.cpp:2301
|
65
|
+
def column_size( col, data )
|
66
|
+
opt = Qt::StyleOptionHeader.new
|
67
|
+
|
68
|
+
# fetch font size
|
69
|
+
fnt = font
|
70
|
+
#~ fnt.bold = true
|
71
|
+
opt.fontMetrics = Qt::FontMetrics.new( fnt )
|
72
|
+
|
73
|
+
# set data
|
74
|
+
opt.text = data.to_s
|
75
|
+
|
76
|
+
# icon size. Not needed
|
77
|
+
#~ variant = d->model->headerData(logicalIndex, d->orientation, Qt::DecorationRole);
|
78
|
+
#~ opt.icon = qvariant_cast<QIcon>(variant);
|
79
|
+
#~ if (opt.icon.isNull())
|
80
|
+
#~ opt.icon = qvariant_cast<QPixmap>(variant);
|
81
|
+
|
82
|
+
size = Qt::Size.new( 100, 30 )
|
83
|
+
# final parameter could be header section
|
84
|
+
style.sizeFromContents( Qt::Style::CT_HeaderSection, opt, size );
|
85
|
+
end
|
86
|
+
|
87
|
+
def relational_delegate( attribute, options )
|
88
|
+
col = model.attributes.index( attribute )
|
89
|
+
delegate = RelationalDelegate.new( self, model.columns[col], options )
|
90
|
+
set_item_delegate_for_column( col, delegate )
|
91
|
+
end
|
92
|
+
|
93
|
+
def delegate( attribute, delegate_class, options = nil )
|
94
|
+
col = model.attributes.index( attribute )
|
95
|
+
delegate = delegate_class.new( self, attribute, options )
|
96
|
+
set_item_delegate_for_column( col, delegate )
|
97
|
+
end
|
98
|
+
|
99
|
+
# is current_index on the last row?
|
100
|
+
def last_row?
|
101
|
+
current_index.row == model.row_count - 1
|
102
|
+
end
|
103
|
+
|
104
|
+
# is current_index on the bottom_right cell?
|
105
|
+
def last_cell?
|
106
|
+
current_index.row == model.row_count - 1 && current_index.column == model.column_count - 1
|
107
|
+
end
|
108
|
+
|
109
|
+
# make sure row size is correct
|
110
|
+
# show error messages for data
|
111
|
+
def setModel( model )
|
112
|
+
vertical_header.default_section_size = vertical_header.minimum_section_size
|
113
|
+
super
|
114
|
+
|
115
|
+
model.connect( SIGNAL( 'data_error(QModelIndex, QVariant, QString)' ) ) do |index,variant,msg|
|
116
|
+
error_message = Qt::ErrorMessage.new( self )
|
117
|
+
error_message.show_message( "Incorrect value '#{variant.value}' entered for field [#{index.attribute.to_s}].\nMessage was: #{msg}" )
|
118
|
+
error_message.show
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# and override this because the Qt bindings don't call
|
123
|
+
# setModel otherwise
|
124
|
+
def model=( model )
|
125
|
+
setModel( model )
|
126
|
+
resize_columns
|
127
|
+
end
|
128
|
+
|
129
|
+
# resize all fields based on heuristics rather
|
130
|
+
# than iterating through the entire data model
|
131
|
+
def resize_columns
|
132
|
+
@builder.fields.each_with_index do |field, index|
|
133
|
+
auto_size_column( index, field.sample )
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def moveCursor( cursor_action, modifiers )
|
138
|
+
# TODO use this as a preload indicator
|
139
|
+
super
|
140
|
+
end
|
141
|
+
|
142
|
+
# paste a CSV array to the index
|
143
|
+
# TODO make additional rows if we need them, or at least check for enough space
|
144
|
+
def paste_to_index( top_left_index, csv_arr )
|
145
|
+
csv_arr.each_with_index do |row,row_index|
|
146
|
+
row.each_with_index do |field, field_index|
|
147
|
+
cell_index = model.create_index( top_left_index.row + row_index, top_left_index.column + field_index )
|
148
|
+
model.setData( cell_index, field.to_variant, Qt::PasteRole )
|
149
|
+
end
|
150
|
+
# save records to db
|
151
|
+
model.save( model.create_index( top_left_index.row + row_index, 0 ) )
|
152
|
+
end
|
153
|
+
|
154
|
+
# make the gui refresh
|
155
|
+
bottom_right_index = model.create_index( top_left_index.row + csv_arr.size - 1, top_left_index.column + csv_arr[0].size - 1 )
|
156
|
+
emit model.dataChanged( top_left_index, bottom_right_index )
|
157
|
+
emit model.headerDataChanged( Qt::Vertical, top_left_index.row, top_left_index.row + csv_arr.size )
|
158
|
+
end
|
159
|
+
|
160
|
+
def delete_multiple_cells?
|
161
|
+
# go ahead with delete if there's only 1 cell, or the user says OK
|
162
|
+
delete_ok =
|
163
|
+
if selection_model.selected_indexes.size > 1
|
164
|
+
# confirmation message, until there are undos
|
165
|
+
msg = Qt::MessageBox.new(
|
166
|
+
Qt::MessageBox::Question,
|
167
|
+
'Multiple Delete',
|
168
|
+
'Are you sure you want to delete multiple cells?',
|
169
|
+
Qt::MessageBox::Yes | Qt::MessageBox::No,
|
170
|
+
self
|
171
|
+
)
|
172
|
+
msg.exec == Qt::MessageBox::Yes
|
173
|
+
else
|
174
|
+
true
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def delete_cells
|
179
|
+
cells_deleted = false
|
180
|
+
|
181
|
+
# do delete
|
182
|
+
if delete_multiple_cells?
|
183
|
+
selection_model.selected_indexes.each do |index|
|
184
|
+
index.attribute_value = nil
|
185
|
+
cells_deleted = true
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# deletes were done, so emit dataChanged
|
190
|
+
if cells_deleted
|
191
|
+
# emit data changed for all ranges
|
192
|
+
selection_model.selection.each do |selection_range|
|
193
|
+
emit dataChanged( selection_range.top_left, selection_range.bottom_right )
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def keyPressEvent( event )
|
199
|
+
# for some reason, trying to call another method inside
|
200
|
+
# the begin .. rescue block throws a superclass method not
|
201
|
+
# found error. Weird.
|
202
|
+
begin
|
203
|
+
# call to model class for shortcuts
|
204
|
+
if model.model_class.respond_to?( :key_press_event )
|
205
|
+
begin
|
206
|
+
model_result = model.model_class.key_press_event( event, current_index, self )
|
207
|
+
return model_result if model_result != nil
|
208
|
+
rescue Exception => e
|
209
|
+
puts e.backtrace
|
210
|
+
error_message = Qt::ErrorMessage.new( self )
|
211
|
+
error_message.show_message( "Error in shortcut handler for #{model.model_class.name}: #{e.message}" )
|
212
|
+
error_message.show
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# now do all the usual shortcuts
|
217
|
+
case
|
218
|
+
# on the last row, and down is pressed
|
219
|
+
# add a new row
|
220
|
+
when event.down? && last_row?
|
221
|
+
model.add_new_item
|
222
|
+
|
223
|
+
# on the right-bottom cell, and tab is pressed
|
224
|
+
# then add a new row
|
225
|
+
when event.tab? && last_cell?
|
226
|
+
model.add_new_item
|
227
|
+
|
228
|
+
# delete the current row
|
229
|
+
when event.ctrl? && event.delete?
|
230
|
+
if delete_multiple_cells?
|
231
|
+
model.remove_rows( selection_model.selected_indexes.map{|index| index.row} )
|
232
|
+
end
|
233
|
+
|
234
|
+
# copy the value from the row one above
|
235
|
+
when event.ctrl? && event.apostrophe?
|
236
|
+
if current_index.row > 0
|
237
|
+
one_up_index = model.create_index( current_index.row - 1, current_index.column )
|
238
|
+
previous_value = one_up_index.attribute_value
|
239
|
+
if current_index.attribute_value != previous_value
|
240
|
+
current_index.attribute_value = previous_value
|
241
|
+
emit model.dataChanged( current_index, current_index )
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
# copy the value from the previous row, one cell right
|
246
|
+
when event.ctrl? && event.bracket_right?
|
247
|
+
if current_index.row > 0 && current_index.column < model.column_count
|
248
|
+
one_up_right_index = model.create_index( current_index.row - 1, current_index.column + 1 )
|
249
|
+
current_index.attribute_value = one_up_right_index.attribute_value
|
250
|
+
emit model.dataChanged( current_index, current_index )
|
251
|
+
end
|
252
|
+
|
253
|
+
# copy the value from the previous row, one cell left
|
254
|
+
when event.ctrl? && event.bracket_left?
|
255
|
+
if current_index.row > 0 && current_index.column > 0
|
256
|
+
one_up_left_index = model.create_index( current_index.row - 1, current_index.column - 1 )
|
257
|
+
current_index.attribute_value = one_up_left_index.attribute_value
|
258
|
+
emit model.dataChanged( current_index, current_index )
|
259
|
+
end
|
260
|
+
|
261
|
+
# insert today's date in the current field
|
262
|
+
when event.ctrl? && event.semicolon?
|
263
|
+
current_index.attribute_value = Time.now
|
264
|
+
emit model.dataChanged( current_index, current_index )
|
265
|
+
|
266
|
+
# dump current record to stdout
|
267
|
+
when event.ctrl? && event.d?
|
268
|
+
puts model.collection[current_index.row].inspect
|
269
|
+
|
270
|
+
# add new record and go to it
|
271
|
+
when event.ctrl? && ( event.n? || event.return? )
|
272
|
+
model.add_new_item
|
273
|
+
new_row_index = model.index( model.collection.size - 1, 0 )
|
274
|
+
currentChanged( new_row_index, current_index )
|
275
|
+
selection_model.clear
|
276
|
+
self.current_index = new_row_index
|
277
|
+
|
278
|
+
# handle deletion of entire rows
|
279
|
+
when event.delete?
|
280
|
+
# translate from ModelIndex objects to row indices
|
281
|
+
rows = vertical_header.selection_model.selected_rows.map{|x| x.row}
|
282
|
+
unless rows.empty?
|
283
|
+
# header rows are selected, so delete them
|
284
|
+
model.remove_rows( rows )
|
285
|
+
# make sure no other handlers get this event
|
286
|
+
return true
|
287
|
+
else
|
288
|
+
# otherwise various cells are selected, so delete the cells
|
289
|
+
delete_cells
|
290
|
+
# nobody else handles this
|
291
|
+
return true
|
292
|
+
end
|
293
|
+
|
294
|
+
# f4 should open editor immediately
|
295
|
+
when event.f4?
|
296
|
+
edit( current_index, Qt::AbstractItemView::AllEditTriggers, event )
|
297
|
+
delegate = item_delegate( current_index )
|
298
|
+
delegate.full_edit
|
299
|
+
|
300
|
+
# copy currently selected data in csv format
|
301
|
+
when event.ctrl? && event.c?
|
302
|
+
text = String.new
|
303
|
+
selection_model.selection.each do |selection_range|
|
304
|
+
(selection_range.top..selection_range.bottom).each do |row|
|
305
|
+
row_ary = Array.new
|
306
|
+
selection_model.selected_indexes.each do |index|
|
307
|
+
row_ary << index.gui_value if index.row == row
|
308
|
+
end
|
309
|
+
text << row_ary.to_csv
|
310
|
+
end
|
311
|
+
end
|
312
|
+
Qt::Application::clipboard.text = text
|
313
|
+
return true
|
314
|
+
|
315
|
+
when event.ctrl? && event.v?
|
316
|
+
# remove trailing "\n" if there is one
|
317
|
+
text = Qt::Application::clipboard.text.chomp
|
318
|
+
arr = FasterCSV.parse( text )
|
319
|
+
|
320
|
+
return true if selection_model.selection.size != 1
|
321
|
+
|
322
|
+
selection_range = selection_model.selection[0]
|
323
|
+
selected_index = selection_model.selected_indexes[0]
|
324
|
+
|
325
|
+
if selection_range.single_cell?
|
326
|
+
# only one cell selected, so paste like a spreadsheet
|
327
|
+
if text.empty?
|
328
|
+
# just clear the current selection
|
329
|
+
model.setData( selected_index, nil.to_variant )
|
330
|
+
else
|
331
|
+
paste_to_index( selected_index, arr )
|
332
|
+
end
|
333
|
+
else
|
334
|
+
return true if selection_range.height != arr.size
|
335
|
+
return true if selection_range.width != arr[0].size
|
336
|
+
|
337
|
+
# size is the same, so do the paste
|
338
|
+
paste_to_index( selected_index, arr )
|
339
|
+
end
|
340
|
+
return true
|
341
|
+
|
342
|
+
else
|
343
|
+
#~ puts event.inspect
|
344
|
+
end
|
345
|
+
super
|
346
|
+
rescue Exception => e
|
347
|
+
puts e.backtrace.join( "\n" )
|
348
|
+
puts e.message
|
349
|
+
error_message = Qt::ErrorMessage.new( self )
|
350
|
+
error_message.show_message( "Error in #{current_index.attribute.to_s}: \"#{e.message}\"" )
|
351
|
+
error_message.show
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
# save the entity in the row of the given index
|
356
|
+
def save_row( index )
|
357
|
+
if index.valid?
|
358
|
+
saved = model.save( index )
|
359
|
+
if !saved
|
360
|
+
error_message = Qt::ErrorMessage.new( self )
|
361
|
+
msg = model.collection[index.row].errors.join("\n")
|
362
|
+
error_message.show_message( msg )
|
363
|
+
error_message.show
|
364
|
+
end
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
# save record whenever its row is exited
|
369
|
+
def currentChanged( current_index, previous_index )
|
370
|
+
@index_override = false
|
371
|
+
if current_index.row != previous_index.row
|
372
|
+
save_row( previous_index )
|
373
|
+
end
|
374
|
+
super
|
375
|
+
end
|
376
|
+
|
377
|
+
# this is to allow entity model UI handlers to tell the view
|
378
|
+
# where to move the current editing index to. If it's left blank
|
379
|
+
# default is based on the editing hint.
|
380
|
+
# see closeEditor
|
381
|
+
def override_next_index( model_index )
|
382
|
+
set_current_index( model_index )
|
383
|
+
@index_override = true
|
384
|
+
end
|
385
|
+
|
386
|
+
# call set_current_index with model_index unless override is true.
|
387
|
+
def set_current_unless_override( model_index )
|
388
|
+
if !@index_override
|
389
|
+
# move to next cell
|
390
|
+
# Qt seems to take care of tab wraparound
|
391
|
+
set_current_index( model_index )
|
392
|
+
end
|
393
|
+
@index_override = false
|
394
|
+
end
|
395
|
+
|
396
|
+
# override to prevent tab pressed from editing next field
|
397
|
+
# also takes into account that override_next_index may have been called
|
398
|
+
def closeEditor( editor, end_edit_hint )
|
399
|
+
case end_edit_hint
|
400
|
+
when Qt::AbstractItemDelegate.EditNextItem
|
401
|
+
super( editor, Qt::AbstractItemDelegate.NoHint )
|
402
|
+
set_current_unless_override( model.create_index( current_index.row, current_index.column + 1 ) )
|
403
|
+
|
404
|
+
when Qt::AbstractItemDelegate.EditPreviousItem
|
405
|
+
super( editor, Qt::AbstractItemDelegate.NoHint )
|
406
|
+
set_current_unless_override( model.create_index( current_index.row, current_index.column - 1 ) )
|
407
|
+
|
408
|
+
else
|
409
|
+
super
|
410
|
+
end
|
411
|
+
end
|
412
|
+
|
413
|
+
# If self.filter is false, use the data in the indexes to filter the data set;
|
414
|
+
# otherwise turn filtering off.
|
415
|
+
# Sets self.filter to true if filtering worked, false otherwise.
|
416
|
+
def filter_by_indexes( indexes )
|
417
|
+
save_entity = current_index.entity
|
418
|
+
save_index = current_index
|
419
|
+
|
420
|
+
unless self.filtered
|
421
|
+
# filter by current selection
|
422
|
+
# TODO handle a multiple-selection
|
423
|
+
if indexes.empty?
|
424
|
+
self.filtered = false
|
425
|
+
elsif indexes.size > 1
|
426
|
+
puts "Can't do multiple selection filters yet"
|
427
|
+
self.filtered = false
|
428
|
+
end
|
429
|
+
|
430
|
+
if indexes[0].entity.new_record?
|
431
|
+
emit status_text( "Can't filter on a new row" )
|
432
|
+
self.filtered = false
|
433
|
+
return
|
434
|
+
else
|
435
|
+
model.reload_data( :conditions => { indexes[0].field_name => indexes[0].field_value } )
|
436
|
+
self.filtered = true
|
437
|
+
end
|
438
|
+
else
|
439
|
+
# unfilter
|
440
|
+
model.reload_data( :conditions => {} )
|
441
|
+
self.filtered = false
|
442
|
+
end
|
443
|
+
|
444
|
+
# find the row for the saved entity
|
445
|
+
found_row = override_cursor( Qt::BusyCursor ) do
|
446
|
+
model.collection.index_for_entity( save_entity )
|
447
|
+
end
|
448
|
+
|
449
|
+
# create a new index and move to it
|
450
|
+
unless found_row.nil?
|
451
|
+
self.current_index = model.create_index( found_row, save_index.column )
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
# search_criteria must respond to:
|
456
|
+
# * search_text
|
457
|
+
# * whole_words?
|
458
|
+
# * direction ( :forward, :backward )
|
459
|
+
# * from_start?
|
460
|
+
#
|
461
|
+
# TODO formalise this
|
462
|
+
def search( search_criteria )
|
463
|
+
indexes = model.search( current_index, search_criteria )
|
464
|
+
if indexes.size > 0
|
465
|
+
emit status_text( "Found #{search_criteria.search_text} at row #{indexes[0].row}" )
|
466
|
+
selection_model.clear
|
467
|
+
self.current_index = indexes[0]
|
468
|
+
else
|
469
|
+
emit status_text( "No match found for #{search_criteria.search_text}" )
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
def itemDelegateForColumn( column )
|
474
|
+
puts "itemDelegateForColumn #{column}"
|
475
|
+
super
|
476
|
+
end
|
477
|
+
end
|
478
|
+
|
479
|
+
end
|