clogs 0.1.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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clogs
4
+ # libui has no clipboard API at all, so Clogs shells out to whatever the
5
+ # platform provides. If nothing is available, copy and paste quietly do
6
+ # nothing rather than raising in the middle of someone's keystroke.
7
+ module Clipboard
8
+ module_function
9
+
10
+ def read
11
+ cmd = read_command
12
+ return @fallback.to_s unless cmd
13
+
14
+ out = `#{cmd} 2>/dev/null`
15
+ $?.success? ? out : @fallback.to_s
16
+ rescue StandardError
17
+ @fallback.to_s
18
+ end
19
+
20
+ def write(text)
21
+ @fallback = text
22
+ cmd = write_command
23
+ return unless cmd
24
+
25
+ IO.popen(cmd, "w") { |io| io.write(text) }
26
+ rescue StandardError
27
+ nil
28
+ end
29
+
30
+ def read_command
31
+ @read_command ||= case RUBY_PLATFORM
32
+ when /darwin/ then "pbpaste"
33
+ when /mingw|mswin/ then "powershell -command Get-Clipboard"
34
+ else
35
+ if system("which xclip > /dev/null 2>&1")
36
+ "xclip -selection clipboard -o"
37
+ elsif system("which xsel > /dev/null 2>&1")
38
+ "xsel --clipboard --output"
39
+ end
40
+ end
41
+ end
42
+
43
+ def write_command
44
+ @write_command ||= case RUBY_PLATFORM
45
+ when /darwin/ then "pbcopy"
46
+ when /mingw|mswin/ then "clip"
47
+ else
48
+ if system("which xclip > /dev/null 2>&1")
49
+ "xclip -selection clipboard -i"
50
+ elsif system("which xsel > /dev/null 2>&1")
51
+ "xsel --clipboard --input"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui"
4
+
5
+ module Clogs
6
+ # Shoes' modal helpers.
7
+ #
8
+ # libui gives us exactly two dialogs of its own -- a message box and a file
9
+ # picker -- and neither can ask a question. `ask` and `confirm` are therefore
10
+ # built as small real windows made of native libui controls, run on a nested
11
+ # `uiMainStep` loop so they block the caller the way Shoes expects.
12
+ module Dialogs
13
+ module_function
14
+
15
+ def alert(parent, message)
16
+ UI::L.msg_box(parent, "", message.to_s)
17
+ nil
18
+ end
19
+
20
+ def error(parent, message)
21
+ UI::L.msg_box_error(parent, "", message.to_s)
22
+ nil
23
+ end
24
+
25
+ def open_file(parent)
26
+ ptr = UI::L.open_file(parent)
27
+ pointer_to_string(ptr)
28
+ end
29
+
30
+ def save_file(parent)
31
+ ptr = UI::L.save_file(parent)
32
+ pointer_to_string(ptr)
33
+ end
34
+
35
+ def open_folder(parent)
36
+ return nil unless UI::L.respond_to?(:open_folder)
37
+
38
+ pointer_to_string(UI::L.open_folder(parent))
39
+ end
40
+
41
+ def pointer_to_string(ptr)
42
+ return nil if ptr.nil? || ptr.null?
43
+
44
+ str = ptr.to_s
45
+ UI::L.free_text(ptr)
46
+ str.empty? ? nil : str
47
+ end
48
+
49
+ # A one-line text prompt. Returns the string, or nil if cancelled.
50
+ def ask(parent, message, secret: false)
51
+ result = nil
52
+ modal(parent, "", 320, 100) do |win, box, finish|
53
+ UI::L.box_append(box, label(message.to_s), 0)
54
+ entry = secret ? UI::L.new_password_entry : UI::L.new_entry
55
+ UI::L.box_append(box, entry, 0)
56
+
57
+ buttons = UI::L.new_horizontal_box
58
+ UI::L.box_set_padded(buttons, 1)
59
+ ok = UI::L.new_button("OK")
60
+ cancel = UI::L.new_button("Cancel")
61
+ UI::L.box_append(buttons, ok, 1)
62
+ UI::L.box_append(buttons, cancel, 1)
63
+ UI::L.box_append(box, buttons, 0)
64
+
65
+ UI::L.button_on_clicked(ok) do
66
+ result = UI::L.entry_text(entry).to_s
67
+ finish.call
68
+ 0
69
+ end
70
+ UI::L.button_on_clicked(cancel) do
71
+ result = nil
72
+ finish.call
73
+ 0
74
+ end
75
+ _ = win
76
+ end
77
+ result
78
+ end
79
+
80
+ # A yes/no question. Returns true or false.
81
+ def confirm(parent, question)
82
+ result = false
83
+ modal(parent, "", 320, 90) do |_win, box, finish|
84
+ UI::L.box_append(box, label(question.to_s), 0)
85
+ buttons = UI::L.new_horizontal_box
86
+ UI::L.box_set_padded(buttons, 1)
87
+ yes = UI::L.new_button("OK")
88
+ no = UI::L.new_button("Cancel")
89
+ UI::L.box_append(buttons, yes, 1)
90
+ UI::L.box_append(buttons, no, 1)
91
+ UI::L.box_append(box, buttons, 0)
92
+
93
+ UI::L.button_on_clicked(yes) do
94
+ result = true
95
+ finish.call
96
+ 0
97
+ end
98
+ UI::L.button_on_clicked(no) do
99
+ result = false
100
+ finish.call
101
+ 0
102
+ end
103
+ end
104
+ result
105
+ end
106
+
107
+ def label(text)
108
+ UI::L.new_label(text)
109
+ end
110
+
111
+ # Run a modal window on a nested event loop. libui does not allow uiMain to
112
+ # be re-entered, but stepping it by hand is fine and is how a blocking
113
+ # dialog has to work here.
114
+ def modal(parent, title, width, height)
115
+ win = UI::L.new_window(title, width, height, 0)
116
+ UI::L.window_set_margined(win, 1)
117
+ box = UI::L.new_vertical_box
118
+ UI::L.box_set_padded(box, 1)
119
+ UI::L.window_set_child(win, box)
120
+
121
+ done = false
122
+ finish = -> { done = true }
123
+ UI::L.window_on_closing(win) do
124
+ done = true
125
+ 0
126
+ end
127
+
128
+ yield win, box, finish
129
+
130
+ UI::L.control_show(win)
131
+ UI::L.main_step(1) until done
132
+ UI::L.control_destroy(win)
133
+ _ = parent
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "drawable"
4
+ require_relative "app"
5
+
6
+ module Clogs
7
+ # Lacci's half of the bargain: it hands us a Shoes drawable class name and an
8
+ # id, and expects a display-side peer back. Everything after that happens
9
+ # through events on that id.
10
+ class DisplayService < Shoes::DisplayService
11
+ class << self
12
+ attr_accessor :instance
13
+ end
14
+
15
+ def initialize
16
+ raise Shoes::SingletonError, "Clogs::DisplayService is a singleton!" if DisplayService.instance
17
+
18
+ DisplayService.instance = self
19
+ super()
20
+ end
21
+
22
+ # Shoes class name (minus namespace) => Clogs peer class.
23
+ PEERS = {
24
+ "App" => "App",
25
+ "DocumentRoot" => "DocumentRoot",
26
+ "Stack" => "Stack",
27
+ "Flow" => "Flow",
28
+ "Shape" => "Shape",
29
+ "Mask" => "Mask",
30
+ "Para" => "Para",
31
+ "Link" => "Link",
32
+ "LinkHover" => "Link",
33
+ "Strong" => "Strong",
34
+ "Em" => "Em",
35
+ "Code" => "Code",
36
+ "Del" => "Del",
37
+ "Ins" => "Ins",
38
+ "Sub" => "Sub",
39
+ "Sup" => "Sup",
40
+ "Span" => "Span",
41
+ "Bg" => "Bg",
42
+ "Fg" => "Fg",
43
+ "Button" => "Button",
44
+ "Check" => "Check",
45
+ "Radio" => "Radio",
46
+ "Progress" => "Progress",
47
+ "EditLine" => "EditLine",
48
+ "EditBox" => "EditBox",
49
+ "ListBox" => "ListBox",
50
+ "Image" => "Image",
51
+ "Video" => "Video",
52
+ "Arrow" => "Arrow",
53
+ "Rect" => "Rect",
54
+ "Oval" => "Oval",
55
+ "Line" => "Line",
56
+ "Star" => "Star",
57
+ "Arc" => "Arc",
58
+ "Background" => "Background",
59
+ "Border" => "Border",
60
+ "SubscriptionItem" => "SubscriptionItem"
61
+ }.freeze
62
+
63
+ def create_display_drawable_for(drawable_class_name, drawable_id, properties, parent_id:, is_widget:)
64
+ existing = query_display_drawable_for(drawable_id, nil_ok: true)
65
+ return existing if existing
66
+
67
+ properties = properties.dup
68
+ properties["shoes_linkable_id"] ||= drawable_id
69
+
70
+ peer = build_peer(drawable_class_name, properties, is_widget)
71
+ set_drawable_pairing(drawable_id, peer)
72
+
73
+ if peer.is_a?(App)
74
+ @app = peer
75
+ else
76
+ parent = parent_id ? query_display_drawable_for(parent_id, nil_ok: true) : nil
77
+ peer.set_parent(parent)
78
+ @document_root = peer if peer.is_a?(DocumentRoot)
79
+ end
80
+
81
+ # Lacci builds the document root *before* the app, so neither one can
82
+ # simply look the other up on creation. Whichever arrives second links
83
+ # them: the root is the app's canvas contents but is not its child.
84
+ @app.document_root = @document_root if @app && @document_root && @app.document_root.nil?
85
+
86
+ peer
87
+ end
88
+
89
+ def build_peer(class_name, properties, is_widget)
90
+ short = class_name.to_s.split("::").last
91
+ return Widget.new(properties) if is_widget && !PEERS.key?(short)
92
+
93
+ peer_name = PEERS[short]
94
+ if peer_name
95
+ Clogs.const_get(peer_name).new(properties)
96
+ else
97
+ # A Shoes drawable Clogs does not know about still needs a peer so the
98
+ # tree stays consistent; it simply draws nothing.
99
+ warn "Clogs: no peer for #{class_name}, ignoring it visually."
100
+ Drawable.new(properties)
101
+ end
102
+ end
103
+
104
+ def destroy
105
+ @app&.destroy
106
+ DisplayService.instance = nil
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+
5
+ module Clogs
6
+ # Base class for every Clogs peer: the display-side half of a Shoes drawable.
7
+ #
8
+ # Lacci owns the Shoes-side object and tells us about it through three events
9
+ # (`parent`, `prop_change`, `destroy`). We own pixels: where the thing sits,
10
+ # how big it is, and what it looks like.
11
+ #
12
+ # The layout contract is two passes:
13
+ #
14
+ # measure(available_width) -> sets @width / @height
15
+ # paint(painter, ox, oy) -> draws, and records absolute geometry so that
16
+ # mouse hit-testing has something to test
17
+ class Drawable < Shoes::Linkable
18
+ attr_accessor :parent, :x, :y
19
+ attr_reader :children, :shoes_linkable_id, :styles, :abs_x, :abs_y, :width, :height
20
+
21
+ class << self
22
+ # Peer class lookup by Shoes class name, e.g. "Shoes::Button" -> Clogs::Button.
23
+ def for_shoes_class(name)
24
+ short = name.split("::").last
25
+ Clogs.const_get(short) if Clogs.const_defined?(short, false)
26
+ end
27
+ end
28
+
29
+ def initialize(properties)
30
+ @styles = {}
31
+ properties.each { |k, v| @styles[k.to_s] = v }
32
+ @shoes_linkable_id = @styles.delete("shoes_linkable_id")
33
+ @children = []
34
+ @x = @y = 0
35
+ @width = @height = 0
36
+
37
+ super(linkable_id: @shoes_linkable_id)
38
+
39
+ bind_shoes_event(event_name: "parent", target: @shoes_linkable_id) do |new_parent_id|
40
+ new_parent = DisplayService.instance.query_display_drawable_for(new_parent_id, nil_ok: true)
41
+ set_parent(new_parent) unless new_parent == @parent
42
+ end
43
+
44
+ bind_shoes_event(event_name: "prop_change", target: @shoes_linkable_id) do |changes|
45
+ changes.each { |k, v| @styles[k.to_s] = v }
46
+ properties_changed(changes)
47
+ end
48
+
49
+ bind_shoes_event(event_name: "destroy", target: @shoes_linkable_id) do
50
+ destroy_self
51
+ end
52
+ end
53
+
54
+ def properties_changed(_changes)
55
+ needs_layout!
56
+ end
57
+
58
+ def destroy_self
59
+ set_parent(nil)
60
+ needs_layout!
61
+ end
62
+
63
+ def set_parent(new_parent)
64
+ @parent&.remove_child(self)
65
+ new_parent&.add_child(self)
66
+ @parent = new_parent
67
+ needs_layout!
68
+ end
69
+
70
+ def add_child(child)
71
+ @children << child unless @children.include?(child)
72
+ end
73
+
74
+ def remove_child(child)
75
+ @children.delete(child)
76
+ end
77
+
78
+ # The owning Clogs::App, found by walking up the tree.
79
+ def app
80
+ @app ||= @parent&.app
81
+ end
82
+
83
+ def needs_layout!
84
+ app&.needs_layout!
85
+ end
86
+
87
+ def redraw!
88
+ app&.redraw!
89
+ end
90
+
91
+ # ---- styles -------------------------------------------------------
92
+
93
+ def style(name)
94
+ @styles[name.to_s]
95
+ end
96
+
97
+ def hidden?
98
+ !!style(:hidden)
99
+ end
100
+
101
+ # A drawable with an explicit left/top is taken out of the normal flow and
102
+ # placed at those coordinates within its slot, exactly as in Shoes.
103
+ def positioned?
104
+ !style(:left).nil? && !style(:top).nil?
105
+ end
106
+
107
+ def margin
108
+ @margin ||= Style.margins(@styles)
109
+ end
110
+
111
+ def requested_width(available)
112
+ Style.dimension(style(:width), available)
113
+ end
114
+
115
+ def requested_height(available)
116
+ Style.dimension(style(:height), available)
117
+ end
118
+
119
+ # ---- layout -------------------------------------------------------
120
+
121
+ # Subclasses override. Must set @width and @height.
122
+ def measure(available_width)
123
+ @width = requested_width(available_width) || 0
124
+ @height = requested_height(available_width) || 0
125
+ end
126
+
127
+ def paint(painter, ox, oy)
128
+ @abs_x = ox + @x
129
+ @abs_y = oy + @y
130
+ draw(painter, @abs_x, @abs_y)
131
+ end
132
+
133
+ def draw(_painter, _x, _y); end
134
+
135
+ # ---- events -------------------------------------------------------
136
+
137
+ def contains?(px, py)
138
+ return false if @abs_x.nil? || hidden?
139
+
140
+ px >= @abs_x && px < @abs_x + @width && py >= @abs_y && py < @abs_y + @height
141
+ end
142
+
143
+ # Overridden by controls that want the keyboard.
144
+ def focusable?
145
+ false
146
+ end
147
+
148
+ # In Shoes any drawable can carry a click handler, so every peer is a
149
+ # candidate; the topmost one under the pointer wins.
150
+ def clickable?
151
+ true
152
+ end
153
+
154
+ def on_click(_x, _y, _button); end
155
+
156
+ def on_release(x, y, button)
157
+ notify("click", button, x.round, y.round) if contains?(x, y)
158
+ end
159
+
160
+ def on_mouse_move(_x, _y); end
161
+
162
+ def on_key(_event)
163
+ false
164
+ end
165
+
166
+ def focus_gained; end
167
+
168
+ def focus_lost; end
169
+
170
+ def notify(event_name, *args)
171
+ send_shoes_event(*args, event_name: event_name, target: @shoes_linkable_id)
172
+ end
173
+
174
+ # Depth-first list of visible peers in paint order, for hit testing.
175
+ def each_peer(&block)
176
+ return if hidden?
177
+
178
+ block.call(self)
179
+ @children.each { |c| c.each_peer(&block) }
180
+ end
181
+ end
182
+ end