wineole 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
1
+ require_relative '../proxy'
2
+ require_relative '../errors'
3
+ require_relative 'passthrough'
4
+ require_relative 'vba_api'
5
+
6
+ module WineOLE
7
+ module MSOffice
8
+ # The tables and checks the three control collections share. Everything
9
+ # here either maps a Ruby-side name onto the COM name Excel wants, or
10
+ # refuses a call before Excel is touched -- the wrapper's job is to make
11
+ # the passthrough's traps unreachable, not to add features to Excel.
12
+ module Controls
13
+ # Legacy form controls: kind => the Worksheet collection whose Add
14
+ # makes one. Every collection takes (Left, Top, Width, Height).
15
+ # EditBox is missing on purpose: it exists only on dialog sheets.
16
+ FORM_KINDS = {
17
+ button: 'Buttons',
18
+ check_box: 'CheckBoxes',
19
+ option_button: 'OptionButtons',
20
+ list_box: 'ListBoxes',
21
+ drop_down: 'DropDowns',
22
+ spinner: 'Spinners',
23
+ scroll_bar: 'ScrollBars',
24
+ label: 'Labels',
25
+ group_box: 'GroupBoxes'
26
+ }.freeze
27
+
28
+ # Shape.FormControlType => kind, for re-binding an existing shape.
29
+ # 3 (xlEditBox) is absent for the reason above, so it maps to nil.
30
+ FORM_CONTROL_TYPES = {
31
+ 0 => :button,
32
+ 1 => :check_box,
33
+ 2 => :drop_down,
34
+ 4 => :group_box,
35
+ 5 => :label,
36
+ 6 => :list_box,
37
+ 7 => :option_button,
38
+ 8 => :scroll_bar,
39
+ 9 => :spinner
40
+ }.freeze
41
+
42
+ # MSForms 2.0: kind => ProgID. Shorthand only -- any String is passed
43
+ # to Excel verbatim as a ProgID, which is how a control outside this
44
+ # table gets placed.
45
+ MSFORMS_KINDS = {
46
+ command_button: 'Forms.CommandButton.1',
47
+ text_box: 'Forms.TextBox.1',
48
+ combo_box: 'Forms.ComboBox.1',
49
+ list_box: 'Forms.ListBox.1',
50
+ check_box: 'Forms.CheckBox.1',
51
+ option_button: 'Forms.OptionButton.1',
52
+ toggle_button: 'Forms.ToggleButton.1',
53
+ spin_button: 'Forms.SpinButton.1',
54
+ scroll_bar: 'Forms.ScrollBar.1',
55
+ label: 'Forms.Label.1',
56
+ image: 'Forms.Image.1'
57
+ }.freeze
58
+
59
+ # A worksheet ActiveX control is two objects: the OLEObject Excel
60
+ # wraps it in, and the MSForms control inside (OLEObject.Object).
61
+ # These keys belong to the host; everything else goes inside.
62
+ HOST_PROPS = %i[linked_cell list_fill_range visible print_object placement].freeze
63
+
64
+ # A VBA identifier. Excel accepts any string as a shape name, but a
65
+ # name that cannot appear in `Name_Click` is a control that can be
66
+ # placed and never handled.
67
+ VBA_NAME = /\A[A-Za-z][A-Za-z0-9_]{0,30}\z/.freeze
68
+
69
+ def self.form_collection_for(kind)
70
+ return FORM_KINDS[kind] if FORM_KINDS.key?(kind)
71
+
72
+ if kind.is_a?(::String)
73
+ raise ArgumentError,
74
+ "form controls have no ProgID (#{kind.inspect} given): a String kind is for " \
75
+ 'sheet.activex and form.controls. Form control kinds are ' \
76
+ "#{FORM_KINDS.keys.map(&:inspect).join(', ')}"
77
+ end
78
+
79
+ raise ArgumentError,
80
+ "unknown form control kind #{kind.inspect} -- expected one of " \
81
+ "#{FORM_KINDS.keys.map(&:inspect).join(', ')}. For an ActiveX control " \
82
+ '(events reach Ruby) use sheet.activex'
83
+ end
84
+
85
+ def self.progid_for(kind)
86
+ return kind if kind.is_a?(::String)
87
+ return MSFORMS_KINDS[kind] if MSFORMS_KINDS.key?(kind)
88
+
89
+ raise ArgumentError,
90
+ "unknown ActiveX kind #{kind.inspect} -- expected one of " \
91
+ "#{MSFORMS_KINDS.keys.map(&:inspect).join(', ')}, or pass a ProgID String " \
92
+ 'for any other registered control'
93
+ end
94
+
95
+ def self.kind_for_progid(progid)
96
+ MSFORMS_KINDS.key(progid) || progid
97
+ end
98
+
99
+ def self.check_name!(name)
100
+ return if name.is_a?(::String) && name.match?(VBA_NAME)
101
+
102
+ raise ArgumentError,
103
+ "name: must be a VBA identifier -- a letter, then letters, digits or " \
104
+ "underscores, at most 31 characters -- because it becomes the `Name_Click` " \
105
+ "handler name. Got #{name.inspect}"
106
+ end
107
+
108
+ def self.check_event!(event)
109
+ return if event.is_a?(::String) && event.match?(VBA_NAME)
110
+
111
+ raise ArgumentError,
112
+ "an event name is a VBA identifier such as 'Click' or 'KeyDown'. Got #{event.inspect}"
113
+ end
114
+
115
+ # Excel allows two shapes with the same name in silence, and a UserForm
116
+ # allows two controls with the same name in silence; after that
117
+ # `Name_Click` means either. A lookup that raises is the free case.
118
+ def self.check_free!(host, name, what)
119
+ host.Item(name)
120
+ rescue WineOLE::RemoteError
121
+ nil
122
+ else
123
+ raise ArgumentError,
124
+ "this #{what} already has a control named #{name.inspect}. Excel would add " \
125
+ 'a second one silently and then Name_Click would be ambiguous; pick another ' \
126
+ 'name or remove the existing control first'
127
+ end
128
+
129
+ # snake_case => PascalCase. COM matches names case-insensitively, so a
130
+ # key already in PascalCase survives the round trip well enough.
131
+ def self.pascal(key)
132
+ key.to_s.split('_').map(&:capitalize).join
133
+ end
134
+
135
+ def self.put(target, key, value)
136
+ target.public_send("#{pascal(key)}=", value)
137
+ end
138
+
139
+ # Either `at:` (a range on `sheet`, read for its box) or all four
140
+ # points. Never a mix, never a partial box, never a default -- "wherever
141
+ # Excel puts it" stays a passthrough behaviour. `sheet: nil` is a
142
+ # UserForm, which has no cells for `at:` to name.
143
+ def self.geometry(sheet:, at:, left:, top:, width:, height:)
144
+ points = { left: left, top: top, width: width, height: height }
145
+ given = points.reject { |_, v| v.nil? }.keys
146
+
147
+ if !at.nil? && !given.empty?
148
+ raise ArgumentError,
149
+ "give either at: or left:/top:/width:/height:, not both (got at: #{at.inspect} " \
150
+ "and #{given.inspect})"
151
+ end
152
+
153
+ unless at.nil?
154
+ if sheet.nil?
155
+ raise ArgumentError,
156
+ 'a UserForm has no cells; give left:, top:, width: and height: in points'
157
+ end
158
+
159
+ range = sheet[at].ole
160
+ return [range.Left, range.Top, range.Width, range.Height]
161
+ end
162
+
163
+ if given.empty?
164
+ raise ArgumentError,
165
+ 'no position given: pass at: "B2:C4" (a range on this sheet) or all four of ' \
166
+ 'left:, top:, width: and height: (points). There is no default position'
167
+ end
168
+
169
+ if given.length < 4
170
+ raise ArgumentError,
171
+ "left:, top:, width: and height: must all be given (missing #{(points.keys - given).inspect})"
172
+ end
173
+
174
+ [left, top, width, height]
175
+ end
176
+ end
177
+
178
+ # One placed control, from any of the three families.
179
+ #
180
+ # TWO OBJECTS, ONE WRAPPER. A worksheet ActiveX control is an OLEObject
181
+ # (Excel's host: Left, Top, Visible, LinkedCell) around an MSForms
182
+ # control (Caption, Value, BackColor). `ole` is the host and
183
+ # `ole_object` the MSForms control; unknown methods go to `ole_object`,
184
+ # because that is where Caption and Value live for every family. Host
185
+ # members are reached explicitly through `ctl.ole`. There is no lookup
186
+ # order that tries both: `Left` exists on neither the inner object
187
+ # (when hosted on a sheet) nor unambiguously on both. For the other two
188
+ # families `ole` and `ole_object` are the same thing.
189
+ #
190
+ # Name note: `name` covers COM `Name` with the same value (M0). The
191
+ # other lowercase methods here -- kind, family, ole, runtime, on, off,
192
+ # events, vba -- are free on Button, OLEObject and the MSForms controls
193
+ # (M0, measured against Excel 11). `ole_object` carries the `ole_`
194
+ # prefix for the reason Proxy's meta-methods do: a bare `object` would
195
+ # shadow COM `Object`, which on an MSForms control is a *different*
196
+ # thing -- the raw control under the extender, with Caption but no Name
197
+ # and no events (M0). `ctl.Object` still reaches that COM member.
198
+ class Control
199
+ include Passthrough
200
+
201
+ attr_reader :name, :kind, :family, :ole_object
202
+
203
+ # `vba:` is the writer a handler goes through: the BookVBA for a form
204
+ # control (any standard module will do, so the wrapper's own), the
205
+ # SheetVBA for worksheet ActiveX (Excel looks for `Name_Click` in the
206
+ # sheet's own module and nowhere else), the BookVBA for a UserForm
207
+ # control (`into:` the form module). `form:` is the Form a :userform
208
+ # control belongs to; it owns the runtime instance.
209
+ def initialize(name:, kind:, family:, ole:, ole_object:, vba:, form: nil)
210
+ @name = name
211
+ @kind = kind
212
+ @family = family
213
+ @ole = ole
214
+ @ole_object = ole_object
215
+ @writer = vba
216
+ @form = form
217
+ end
218
+
219
+ # The Events object a block would be registered on; nil for a form
220
+ # control, which has none.
221
+ def events
222
+ case @family
223
+ when :activex then @ole_object.ole_events
224
+ when :userform then runtime.ole_events
225
+ end
226
+ end
227
+
228
+ # A UserForm control's live counterpart on the form's default
229
+ # instance -- the object that fires events and shows changes while the
230
+ # form is loaded. The design-time control (`ole`) does neither.
231
+ def runtime
232
+ unless @family == :userform
233
+ raise ArgumentError,
234
+ "only a UserForm control has a runtime instance; this is a #{@family}"
235
+ end
236
+
237
+ @form.runtime_control(@name)
238
+ end
239
+
240
+ def on(event, args: true, &block)
241
+ listenable.on(event, args: args, &block)
242
+ end
243
+
244
+ def off(name_or_subscription)
245
+ listenable.off(name_or_subscription)
246
+ end
247
+
248
+ # Write one VBA handler. A form control fires only Click, so it takes
249
+ # the body alone and is bound through OnAction; the other two take the
250
+ # event name, and Excel finds the procedure by its `Name_Event` name in
251
+ # the right module. `params:` is the parameter list, verbatim -- the
252
+ # wrapper carries no signature table.
253
+ #
254
+ # The block is named `Name_Event`, so writing the same event again
255
+ # replaces the handler (vba.write's own rule).
256
+ def vba(event_or_body, body = nil, params: nil)
257
+ if @family == :form_control
258
+ unless body.nil?
259
+ raise ArgumentError,
260
+ 'a form control fires only Click: call vba(body) with no event name'
261
+ end
262
+
263
+ macro = "#{@name}_Click"
264
+ @writer.write("Sub #{macro}()\n#{indent(event_or_body)}\nEnd Sub", name: macro)
265
+ @ole.OnAction = macro
266
+ else
267
+ if body.nil?
268
+ raise ArgumentError,
269
+ "vba(event, body) -- name the event, e.g. vba('Click', 'Range(\"A1\").Value = 1')"
270
+ end
271
+
272
+ Controls.check_event!(event_or_body)
273
+ block = "#{@name}_#{event_or_body}"
274
+ code = "Private Sub #{block}(#{params})\n#{indent(body)}\nEnd Sub"
275
+ if @family == :activex
276
+ @writer.write(code, name: block)
277
+ else
278
+ @writer.write(code, name: block, into: @form.name)
279
+ end
280
+ end
281
+ self
282
+ end
283
+
284
+ private
285
+
286
+ def passthrough_target
287
+ @ole_object
288
+ end
289
+
290
+ def listenable
291
+ if @family == :form_control
292
+ raise ArgumentError,
293
+ 'form controls have no COM events; bind a macro with vba(...) or use ' \
294
+ 'sheet.activex for a control Ruby can listen to'
295
+ end
296
+
297
+ events
298
+ end
299
+
300
+ def indent(body)
301
+ body.to_s.chomp.split(/\r?\n/, -1).map { |line| line.empty? ? line : " #{line}" }.join("\n")
302
+ end
303
+ end
304
+
305
+ # `sheet.form_controls`: the Forms-toolbar controls. Cheap to place (a
306
+ # `Buttons.Add` plus `OnAction` measured 3 ms) and they save with the
307
+ # workbook, but they raise no COM events -- a handler is a macro named
308
+ # by OnAction, so `vba(body)` is the only way to react to one.
309
+ #
310
+ # Name note (M0): `form_controls` is free on Worksheet.
311
+ class FormControls
312
+ def initialize(sheet)
313
+ @sheet = sheet
314
+ end
315
+
316
+ # Order matters and each step exists to make a specific passthrough
317
+ # trap unreachable: kind and name are checked before geometry so a
318
+ # typo fails without a round trip; the free-name check runs against
319
+ # Shapes, which sees every family; the rename and the properties run
320
+ # after Add with the new control deleted if either fails, so a refused
321
+ # property does not leave an unnamed button behind.
322
+ def add(kind, name:, at: nil, left: nil, top: nil, width: nil, height: nil, **props)
323
+ collection = Controls.form_collection_for(kind)
324
+ Controls.check_name!(name)
325
+ l, t, w, h = Controls.geometry(sheet: @sheet, at: at, left: left, top: top, width: width, height: height)
326
+ Controls.check_free!(@sheet.ole.Shapes, name, 'sheet')
327
+
328
+ ole = @sheet.ole.public_send(collection).Add(l, t, w, h)
329
+ begin
330
+ ole.Name = name
331
+ props.each { |key, value| Controls.put(ole, key, value) }
332
+ rescue StandardError
333
+ ole.Delete
334
+ raise
335
+ end
336
+ Control.new(name: name, kind: kind, family: :form_control, ole: ole, ole_object: ole, vba: book_vba)
337
+ end
338
+
339
+ # Re-bind a control that is already on the sheet. nil when there is no
340
+ # shape of that name, or the shape is not a form control (an ActiveX
341
+ # shape raises on FormControlType; an EditBox maps to nil).
342
+ def [](name)
343
+ kind = Controls::FORM_CONTROL_TYPES[@sheet.ole.Shapes.Item(name).FormControlType]
344
+ return nil if kind.nil?
345
+
346
+ ole = @sheet.ole.public_send(Controls::FORM_KINDS[kind]).Item(name)
347
+ Control.new(name: name, kind: kind, family: :form_control, ole: ole, ole_object: ole, vba: book_vba)
348
+ rescue WineOLE::RemoteError
349
+ nil
350
+ end
351
+
352
+ private
353
+
354
+ # A form control's macro can live in any standard module, so it goes
355
+ # in the wrapper's own module of the parent workbook. Only `write` is
356
+ # used, and paths never are, so convert_paths is moot.
357
+ def book_vba
358
+ @book_vba ||= BookVBA.new(@sheet.ole.Parent, convert_paths: false)
359
+ end
360
+ end
361
+
362
+ # `sheet.activex`: OLEObjects hosting an MSForms control (or any other
363
+ # registered control, by ProgID). Each is two COM objects -- see
364
+ # Control's class comment -- and the properties given at placement are
365
+ # routed accordingly: HOST_PROPS to the OLEObject, the rest inside.
366
+ #
367
+ # The five named arguments to OLEObjects.Add are not optional on Excel
368
+ # 11: with only Left and Top it fails with 0x800A03EC. Geometry has
369
+ # already guaranteed all four points by the time Add is called.
370
+ #
371
+ # Name note (M0): `activex` is free on Worksheet.
372
+ class ActiveXControls
373
+ def initialize(sheet)
374
+ @sheet = sheet
375
+ end
376
+
377
+ def add(kind, name:, at: nil, left: nil, top: nil, width: nil, height: nil, **props)
378
+ progid = Controls.progid_for(kind)
379
+ Controls.check_name!(name)
380
+ l, t, w, h = Controls.geometry(sheet: @sheet, at: at, left: left, top: top, width: width, height: height)
381
+ Controls.check_free!(@sheet.ole.Shapes, name, 'sheet')
382
+
383
+ ole = @sheet.ole.OLEObjects.Add(ClassType: progid, Left: l, Top: t, Width: w, Height: h)
384
+ begin
385
+ ole.Name = name
386
+ inner = ole.Object
387
+ props.each do |key, value|
388
+ Controls.put(Controls::HOST_PROPS.include?(key) ? ole : inner, key, value)
389
+ end
390
+ rescue StandardError
391
+ ole.Delete
392
+ raise
393
+ end
394
+ Control.new(name: name, kind: kind, family: :activex, ole: ole, ole_object: inner, vba: @sheet.vba)
395
+ end
396
+
397
+ def [](name)
398
+ ole = @sheet.ole.OLEObjects.Item(name)
399
+ Control.new(name: name, kind: Controls.kind_for_progid(ole.progID), family: :activex,
400
+ ole: ole, ole_object: ole.Object, vba: @sheet.vba)
401
+ rescue WineOLE::RemoteError
402
+ nil
403
+ end
404
+ end
405
+
406
+ # `form.controls`: MSForms controls on a UserForm, placed on the
407
+ # design-time Designer. Points only -- a form has no cells for `at:`.
408
+ # MSForms takes the name at Add time, so there is no rename step, and
409
+ # the box is four property puts after it (Controls.Add has no position
410
+ # arguments).
411
+ #
412
+ # Name note (M0): `controls` shadows `Designer.Controls` deliberately;
413
+ # the COM collection is still reachable as `form.ole.Controls`.
414
+ class UserFormControls
415
+ def initialize(form, book_vba)
416
+ @form = form
417
+ @book_vba = book_vba
418
+ end
419
+
420
+ def add(kind, name:, at: nil, left: nil, top: nil, width: nil, height: nil, **props)
421
+ progid = Controls.progid_for(kind)
422
+ Controls.check_name!(name)
423
+ l, t, w, h = Controls.geometry(sheet: nil, at: at, left: left, top: top, width: width, height: height)
424
+ Controls.check_free!(@form.ole.Controls, name, 'UserForm')
425
+
426
+ ole = @form.ole.Controls.Add(progid, name)
427
+ begin
428
+ ole.Left = l
429
+ ole.Top = t
430
+ ole.Width = w
431
+ ole.Height = h
432
+ props.each { |key, value| Controls.put(ole, key, value) }
433
+ rescue StandardError
434
+ @form.ole.Controls.Remove(name)
435
+ raise
436
+ end
437
+ Control.new(name: name, kind: kind, family: :userform, ole: ole, ole_object: ole, vba: @book_vba, form: @form)
438
+ end
439
+
440
+ # kind is nil: a placed MSForms control does not report the ProgID it
441
+ # was made from.
442
+ def [](name)
443
+ ole = @form.ole.Controls.Item(name)
444
+ Control.new(name: name, kind: nil, family: :userform, ole: ole, ole_object: ole, vba: @book_vba, form: @form)
445
+ rescue WineOLE::RemoteError
446
+ nil
447
+ end
448
+ end
449
+ end
450
+ end