ranma 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.
- checksums.yaml +7 -0
- data/Cargo.lock +5571 -0
- data/Cargo.toml +3 -0
- data/LICENSE +21 -0
- data/README.md +293 -0
- data/ext/ranma/Cargo.toml +23 -0
- data/ext/ranma/extconf.rb +4 -0
- data/ext/ranma/src/app.rs +231 -0
- data/ext/ranma/src/blit.wgsl +32 -0
- data/ext/ranma/src/clipboard.rs +57 -0
- data/ext/ranma/src/dpi.rs +227 -0
- data/ext/ranma/src/event_loop.rs +145 -0
- data/ext/ranma/src/events.rs +305 -0
- data/ext/ranma/src/hotkey.rs +245 -0
- data/ext/ranma/src/ime.rs +318 -0
- data/ext/ranma/src/lib.rs +42 -0
- data/ext/ranma/src/menu.rs +588 -0
- data/ext/ranma/src/monitor.rs +149 -0
- data/ext/ranma/src/painter.rs +1082 -0
- data/ext/ranma/src/proxy.rs +34 -0
- data/ext/ranma/src/surface.rs +389 -0
- data/ext/ranma/src/theme.rs +20 -0
- data/ext/ranma/src/tray.rs +251 -0
- data/ext/ranma/src/webview.rs +334 -0
- data/ext/ranma/src/window.rs +691 -0
- data/ext/ranma/src/window_store.rs +81 -0
- data/lib/ranma/version.rb +3 -0
- data/lib/ranma.rb +61 -0
- metadata +86 -0
data/Cargo.toml
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yasushi Itoh
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# ranma
|
|
2
|
+
|
|
3
|
+
> Ruby bindings for native windowing, GPU 2D rendering, and WebView — powered by Rust.
|
|
4
|
+
> Wraps [tao](https://github.com/tauri-apps/tao), [wry](https://github.com/tauri-apps/wry),
|
|
5
|
+
> [Vello](https://github.com/linebender/vello), [muda](https://github.com/tauri-apps/muda), and more.
|
|
6
|
+
|
|
7
|
+
[](https://badge.fury.io/rb/ranma)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Native windows with full event handling (keyboard, mouse, IME, resize, DPI)
|
|
14
|
+
- Native menu bar (via [muda](https://github.com/tauri-apps/muda))
|
|
15
|
+
- System tray icon with menu (via [tray-icon](https://github.com/tauri-apps/tray-icon))
|
|
16
|
+
- Embedded WebView (WKWebView on macOS, via [wry](https://github.com/tauri-apps/wry))
|
|
17
|
+
- GPU-accelerated 2D graphics — rects, circles, arcs, paths, text, images
|
|
18
|
+
(via [Vello](https://github.com/linebender/vello) + wgpu)
|
|
19
|
+
- Clipboard read/write (via [arboard](https://github.com/1Password/arboard))
|
|
20
|
+
- Global hotkeys (via [global-hotkey](https://github.com/tauri-apps/global-hotkey))
|
|
21
|
+
- System theme detection — dark/light mode (via [dark-light](https://github.com/rust-dark-light/dark-light))
|
|
22
|
+
- Multi-monitor support (monitor enumeration, video modes, exclusive fullscreen)
|
|
23
|
+
- IME preedit support (macOS)
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- macOS (primary platform; tao also targets Linux and Windows but those are untested)
|
|
28
|
+
- Ruby >= 3.0
|
|
29
|
+
- Rust toolchain (`cargo`) — required to compile the native extension
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Add to your Gemfile:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
gem "ranma"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then run:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bundle install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or install directly:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
gem install ranma
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require "ranma"
|
|
55
|
+
|
|
56
|
+
Ranma::App.start do
|
|
57
|
+
window = Ranma::AppWindow.new(
|
|
58
|
+
title: "Hello Ranma!",
|
|
59
|
+
inner_size: Ranma::LogicalSize.new(800, 600)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
window.on_event do |event|
|
|
63
|
+
case event[:type]
|
|
64
|
+
when :close_requested
|
|
65
|
+
Ranma::App.exit
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
window.visible = true
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
### App Lifecycle
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Ranma::App.start { ... } # Start the event loop; block runs before the loop begins
|
|
79
|
+
Ranma::App.exit # Request application exit
|
|
80
|
+
Ranma::App.request_redraw # Request a redraw of all windows
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Window (`Ranma::AppWindow`)
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
window = Ranma::AppWindow.new(
|
|
87
|
+
title: "My App",
|
|
88
|
+
inner_size: Ranma::LogicalSize.new(800, 600)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
window.on_event { |event| ... } # Per-window event handler
|
|
92
|
+
|
|
93
|
+
window.visible = true
|
|
94
|
+
window.title = "New Title"
|
|
95
|
+
window.resizable = true
|
|
96
|
+
window.decorations = true
|
|
97
|
+
window.inner_size # => Ranma::LogicalSize
|
|
98
|
+
window.outer_position # => Ranma::LogicalPosition
|
|
99
|
+
window.scale_factor # => Float
|
|
100
|
+
|
|
101
|
+
window.set_fullscreen_borderless # Borderless fullscreen
|
|
102
|
+
window.set_fullscreen_exclusive(video_mode) # Exclusive fullscreen
|
|
103
|
+
window.set_ime_position([x, y]) # or Ranma::LogicalPosition.new(x, y)
|
|
104
|
+
window.setup_ime_preedit # Enable IME preedit events (macOS)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Events (Hash with `:type` key)
|
|
108
|
+
|
|
109
|
+
| Event type | Additional fields |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `:close_requested` | — |
|
|
112
|
+
| `:resized` | `:width`, `:height` |
|
|
113
|
+
| `:moved` | `:x`, `:y` |
|
|
114
|
+
| `:focused` | `:focused` (bool) |
|
|
115
|
+
| `:keyboard_input` | `:state`, `:key_code`, `:modifiers` |
|
|
116
|
+
| `:received_ime_text` | `:text` |
|
|
117
|
+
| `:ime_preedit` | `:text`, `:cursor_start`, `:cursor_end` |
|
|
118
|
+
| `:cursor_moved` | `:x`, `:y` |
|
|
119
|
+
| `:mouse_input` | `:state`, `:button` |
|
|
120
|
+
| `:mouse_wheel` | `:delta_x`, `:delta_y` |
|
|
121
|
+
| `:scale_factor_changed` | `:scale_factor` |
|
|
122
|
+
| `:theme_changed` | `:theme` (`:dark` or `:light`) |
|
|
123
|
+
| `:redraw_requested` | — |
|
|
124
|
+
|
|
125
|
+
### Menus
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
menu = Ranma::Menu.new
|
|
129
|
+
submenu = Ranma::Submenu.new("File", enabled: true)
|
|
130
|
+
item = Ranma::MenuItem.new("Open", enabled: true, accelerator: "CmdOrCtrl+O")
|
|
131
|
+
check = Ranma::CheckMenuItem.new("Show Toolbar", enabled: true, checked: true)
|
|
132
|
+
sep = Ranma::PredefinedMenuItem.separator
|
|
133
|
+
quit = Ranma::PredefinedMenuItem.quit
|
|
134
|
+
|
|
135
|
+
submenu.append(item)
|
|
136
|
+
submenu.append(check)
|
|
137
|
+
submenu.append(sep)
|
|
138
|
+
submenu.append(quit)
|
|
139
|
+
menu.append(submenu)
|
|
140
|
+
menu.init_for_nsapp # macOS: set as the application menu
|
|
141
|
+
|
|
142
|
+
Ranma::App.on_menu_event { |event| puts event[:id] }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### System Tray
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
tray = Ranma::TrayIcon.new(icon_path: "icon.png", menu: menu)
|
|
149
|
+
|
|
150
|
+
Ranma::App.on_tray_event { |event| puts event.inspect }
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### WebView
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
webview = Ranma::WebView.new(window, url: "https://example.com")
|
|
157
|
+
# or
|
|
158
|
+
webview = Ranma::WebView.new(window, html: "<h1>Hello</h1>")
|
|
159
|
+
|
|
160
|
+
webview.load_url("https://example.com")
|
|
161
|
+
webview.load_html("<h1>Hello</h1>")
|
|
162
|
+
webview.evaluate_script("document.title")
|
|
163
|
+
webview.reload
|
|
164
|
+
webview.zoom(1.5)
|
|
165
|
+
webview.set_bounds(0, 0, 800, 600) # positional: (x, y, width, height)
|
|
166
|
+
webview.set_visible(true)
|
|
167
|
+
|
|
168
|
+
webview.on_ipc_message { |msg| puts msg }
|
|
169
|
+
webview.on_navigation { |url| puts url }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### GPU 2D Graphics
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
surface = Ranma::GpuSurface.new(window)
|
|
176
|
+
painter = Ranma::Painter.new(surface)
|
|
177
|
+
|
|
178
|
+
window.on_event do |event|
|
|
179
|
+
if event[:type] == :redraw_requested
|
|
180
|
+
painter.clear_all("#ffffff")
|
|
181
|
+
|
|
182
|
+
style = Ranma::PainterStyle.new(
|
|
183
|
+
fill_color: "#3366cc",
|
|
184
|
+
stroke_color: "#000000",
|
|
185
|
+
stroke_width: 2.0,
|
|
186
|
+
font_size: 24.0,
|
|
187
|
+
border_radius: 8.0
|
|
188
|
+
)
|
|
189
|
+
painter.style(style)
|
|
190
|
+
|
|
191
|
+
painter.fill_rect(10, 10, 200, 100)
|
|
192
|
+
painter.stroke_rect(10, 10, 200, 100)
|
|
193
|
+
painter.fill_circle(150, 150, 50)
|
|
194
|
+
painter.fill_text("Hello!", 50, 50, nil) # 4th arg: max_width (Float or nil)
|
|
195
|
+
|
|
196
|
+
painter.save
|
|
197
|
+
painter.translate(100, 100)
|
|
198
|
+
painter.scale(2.0, 2.0)
|
|
199
|
+
painter.fill_rect(0, 0, 50, 50)
|
|
200
|
+
painter.restore
|
|
201
|
+
|
|
202
|
+
painter.flush
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### Available drawing methods
|
|
208
|
+
|
|
209
|
+
| Method | Description |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `clear_all(color_hex = nil)` | Fill entire surface with a hex color (e.g. `"#ffffff"`); omit to clear to transparent |
|
|
212
|
+
| `style(painter_style)` | Set the active `Ranma::PainterStyle` for subsequent draw calls |
|
|
213
|
+
| `fill_rect(x, y, w, h)` | Filled rectangle (respects `border_radius` from active style) |
|
|
214
|
+
| `stroke_rect(x, y, w, h)` | Stroked rectangle |
|
|
215
|
+
| `fill_circle(cx, cy, r)` | Filled circle |
|
|
216
|
+
| `fill_arc(cx, cy, r, start_angle, end_angle)` | Filled arc |
|
|
217
|
+
| `stroke_arc(cx, cy, r, start_angle, end_angle)` | Stroked arc |
|
|
218
|
+
| `fill_triangle(x1,y1, x2,y2, x3,y3)` | Filled triangle |
|
|
219
|
+
| `fill_text(text, x, y, max_width)` | Text rendering; `max_width` is a Float or `nil` |
|
|
220
|
+
| `measure_text(text)` | Returns `Ranma::PainterFontMetrics` |
|
|
221
|
+
| `draw_image(path, x, y, w, h)` | Draw image from file |
|
|
222
|
+
| `measure_image(path)` | Returns `[width, height]` |
|
|
223
|
+
| `begin_path / path_move_to / path_line_to / fill_path` | Path drawing |
|
|
224
|
+
| `save / restore` | Save/restore transform+clip state |
|
|
225
|
+
| `translate(dx, dy) / scale(sx, sy)` | Transform |
|
|
226
|
+
| `clip(x, y, w, h)` | Clip rectangle |
|
|
227
|
+
| `flush` | Submit frame to GPU |
|
|
228
|
+
|
|
229
|
+
### Clipboard
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
Ranma::Clipboard.set_text("Hello, world!")
|
|
233
|
+
text = Ranma::Clipboard.get_text
|
|
234
|
+
Ranma::Clipboard.clear
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### HotKey
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
hotkey = Ranma::HotKey.new(modifiers: [:ctrl, :shift], key: :a)
|
|
241
|
+
hotkey.register { puts "Ctrl+Shift+A pressed!" }
|
|
242
|
+
hotkey.unregister
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Theme
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
theme = Ranma::Theme.detect # => :dark | :light | :unspecified
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### DPI Types
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
Ranma::LogicalSize.new(width, height)
|
|
255
|
+
Ranma::PhysicalSize.new(width, height)
|
|
256
|
+
Ranma::LogicalPosition.new(x, y)
|
|
257
|
+
Ranma::PhysicalPosition.new(x, y)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Examples
|
|
261
|
+
|
|
262
|
+
The `examples/` directory contains runnable demonstrations:
|
|
263
|
+
|
|
264
|
+
| File | Description |
|
|
265
|
+
|---|---|
|
|
266
|
+
| `basic_window.rb` | Minimal window using the high-level API |
|
|
267
|
+
| `event_handling.rb` | Per-window event handling |
|
|
268
|
+
| `low_level.rb` | Low-level `Ranma.run` API |
|
|
269
|
+
| `menu_example.rb` | Native menu bar with menu events |
|
|
270
|
+
| `full_featured.rb` | Full demo: menus, tray, IME, monitors |
|
|
271
|
+
| `clipboard_example.rb` | Clipboard read/write operations |
|
|
272
|
+
| `hotkey_example.rb` | Global hotkey registration |
|
|
273
|
+
| `webview_example.rb` | Embedded WebView (wry) |
|
|
274
|
+
| `painter_example.rb` | GPU-accelerated 2D drawing (Vello + Painter) |
|
|
275
|
+
|
|
276
|
+
Run any example with:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
bundle exec ruby examples/basic_window.rb
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Building from Source
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
git clone https://github.com/i2y/ranma
|
|
286
|
+
cd ranma
|
|
287
|
+
bundle install
|
|
288
|
+
bundle exec rake compile
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "ranma"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
publish = false
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
crate-type = ["cdylib"]
|
|
9
|
+
|
|
10
|
+
[dependencies]
|
|
11
|
+
magnus = "0.8"
|
|
12
|
+
rb-sys = "*"
|
|
13
|
+
tao = "0.34"
|
|
14
|
+
muda = "0.17"
|
|
15
|
+
tray-icon = "0.21"
|
|
16
|
+
dark-light = "2.0"
|
|
17
|
+
arboard = "3.6"
|
|
18
|
+
global-hotkey = "0.7"
|
|
19
|
+
wry = { version = "0.54", features = ["devtools"] }
|
|
20
|
+
vello = "0.7"
|
|
21
|
+
parley = "0.4"
|
|
22
|
+
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
|
23
|
+
pollster = "0.4"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
use std::cell::RefCell;
|
|
2
|
+
use std::sync::OnceLock;
|
|
3
|
+
use magnus::{function, gc, prelude::*, block::Proc, Error, Ruby, Symbol, Value};
|
|
4
|
+
use tao::event::{Event, StartCause};
|
|
5
|
+
use tao::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget};
|
|
6
|
+
|
|
7
|
+
#[cfg(target_os = "macos")]
|
|
8
|
+
use tao::platform::macos::{ActivationPolicy, EventLoopExtMacOS};
|
|
9
|
+
|
|
10
|
+
use crate::events::convert_window_event;
|
|
11
|
+
use crate::window_store;
|
|
12
|
+
|
|
13
|
+
#[derive(Debug)]
|
|
14
|
+
pub enum UserEvent {
|
|
15
|
+
Exit,
|
|
16
|
+
Redraw,
|
|
17
|
+
MenuEvent(muda::MenuEvent),
|
|
18
|
+
TrayEvent(tray_icon::TrayIconEvent),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static GLOBAL_PROXY: OnceLock<EventLoopProxy<UserEvent>> = OnceLock::new();
|
|
22
|
+
|
|
23
|
+
fn sym(ruby: &Ruby, name: &str) -> Symbol {
|
|
24
|
+
ruby.sym_new(name).into()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
thread_local! {
|
|
28
|
+
static EVENT_LOOP_TARGET: RefCell<Option<*const EventLoopWindowTarget<UserEvent>>> = const { RefCell::new(None) };
|
|
29
|
+
static EXIT_PROXY: RefCell<Option<tao::event_loop::EventLoopProxy<UserEvent>>> = const { RefCell::new(None) };
|
|
30
|
+
static APP_MENU_HANDLER: RefCell<Option<Value>> = const { RefCell::new(None) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub fn with_event_loop_target<F, R>(f: F) -> Option<R>
|
|
34
|
+
where
|
|
35
|
+
F: FnOnce(&EventLoopWindowTarget<UserEvent>) -> R,
|
|
36
|
+
{
|
|
37
|
+
EVENT_LOOP_TARGET.with(|cell| {
|
|
38
|
+
let borrow = cell.borrow();
|
|
39
|
+
borrow.map(|ptr| {
|
|
40
|
+
let target = unsafe { &*ptr };
|
|
41
|
+
f(target)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn ranma_app_start(callback: Proc) -> Result<(), Error> {
|
|
47
|
+
let mut event_loop = EventLoopBuilder::with_user_event().build();
|
|
48
|
+
|
|
49
|
+
#[cfg(target_os = "macos")]
|
|
50
|
+
{
|
|
51
|
+
event_loop.set_activation_policy(ActivationPolicy::Regular);
|
|
52
|
+
event_loop.set_activate_ignoring_other_apps(true);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let proxy = event_loop.create_proxy();
|
|
56
|
+
EXIT_PROXY.with(|cell| {
|
|
57
|
+
*cell.borrow_mut() = Some(proxy);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let _ = GLOBAL_PROXY.set(event_loop.create_proxy());
|
|
61
|
+
|
|
62
|
+
// Set up muda menu event handler to forward events via the event loop proxy
|
|
63
|
+
let menu_proxy = event_loop.create_proxy();
|
|
64
|
+
muda::MenuEvent::set_event_handler(Some(move |event| {
|
|
65
|
+
let _ = menu_proxy.send_event(UserEvent::MenuEvent(event));
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Set up tray-icon event handler to forward events via the event loop proxy
|
|
69
|
+
let tray_proxy = event_loop.create_proxy();
|
|
70
|
+
tray_icon::TrayIconEvent::set_event_handler(Some(move |event| {
|
|
71
|
+
let _ = tray_proxy.send_event(UserEvent::TrayEvent(event));
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// Set up the event loop target BEFORE calling the callback,
|
|
75
|
+
// so that AppWindow.new can use it to create windows.
|
|
76
|
+
EVENT_LOOP_TARGET.with(|cell| {
|
|
77
|
+
let target: &EventLoopWindowTarget<UserEvent> = &event_loop;
|
|
78
|
+
*cell.borrow_mut() = Some(target as *const _);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Initialize hotkey manager before setup callback
|
|
82
|
+
crate::hotkey::init_manager();
|
|
83
|
+
|
|
84
|
+
// Call the setup callback BEFORE run().
|
|
85
|
+
let result = callback.call::<_, Value>(());
|
|
86
|
+
if let Err(e) = result {
|
|
87
|
+
eprintln!("Ranma: Error in App.start callback: {}", e);
|
|
88
|
+
return Err(e);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
event_loop.run(move |event, event_loop_window_target, control_flow| {
|
|
92
|
+
*control_flow = ControlFlow::Wait;
|
|
93
|
+
|
|
94
|
+
// Allow Ruby background threads (e.g. image downloaders) to run.
|
|
95
|
+
// Without this, the GVL is held continuously and other threads starve.
|
|
96
|
+
unsafe { rb_sys::rb_thread_schedule(); }
|
|
97
|
+
|
|
98
|
+
EVENT_LOOP_TARGET.with(|cell| {
|
|
99
|
+
*cell.borrow_mut() = Some(event_loop_window_target as *const _);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
103
|
+
|
|
104
|
+
match event {
|
|
105
|
+
Event::NewEvents(StartCause::Init) => {
|
|
106
|
+
crate::tray::create_pending_trays();
|
|
107
|
+
}
|
|
108
|
+
Event::NewEvents(_) => {
|
|
109
|
+
while let Ok(event) = global_hotkey::GlobalHotKeyEvent::receiver().try_recv() {
|
|
110
|
+
crate::hotkey::dispatch_hotkey_event(event.id());
|
|
111
|
+
}
|
|
112
|
+
crate::ime::dispatch_pending_preedit();
|
|
113
|
+
}
|
|
114
|
+
Event::UserEvent(UserEvent::Exit) => {
|
|
115
|
+
*control_flow = ControlFlow::Exit;
|
|
116
|
+
}
|
|
117
|
+
Event::UserEvent(UserEvent::Redraw) => {
|
|
118
|
+
window_store::request_redraw_all();
|
|
119
|
+
}
|
|
120
|
+
Event::UserEvent(UserEvent::MenuEvent(e)) => {
|
|
121
|
+
APP_MENU_HANDLER.with(|cell| {
|
|
122
|
+
let borrow = cell.borrow();
|
|
123
|
+
if let Some(ref handler) = *borrow {
|
|
124
|
+
let hash = ruby.hash_new();
|
|
125
|
+
let _ = hash.aset(sym(&ruby, "type"), sym(&ruby, "menu_event"));
|
|
126
|
+
let _ = hash.aset(sym(&ruby, "menu_id"), e.id.0.clone());
|
|
127
|
+
let result: Result<Value, _> = handler.funcall("call", (hash,));
|
|
128
|
+
if let Err(err) = result {
|
|
129
|
+
eprintln!("Ranma: Error in menu event handler: {}", err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
Event::UserEvent(UserEvent::TrayEvent(e)) => {
|
|
135
|
+
if let tray_icon::TrayIconEvent::Click {
|
|
136
|
+
position,
|
|
137
|
+
button,
|
|
138
|
+
button_state: tray_icon::MouseButtonState::Up,
|
|
139
|
+
..
|
|
140
|
+
} = e
|
|
141
|
+
{
|
|
142
|
+
if crate::tray::has_tray_event_handler() {
|
|
143
|
+
let hash = ruby.hash_new();
|
|
144
|
+
let _ = hash.aset(sym(&ruby, "type"), sym(&ruby, "tray_click"));
|
|
145
|
+
let _ = hash.aset(sym(&ruby, "x"), position.x);
|
|
146
|
+
let _ = hash.aset(sym(&ruby, "y"), position.y);
|
|
147
|
+
let btn_sym = match button {
|
|
148
|
+
tray_icon::MouseButton::Left => "left",
|
|
149
|
+
tray_icon::MouseButton::Right => "right",
|
|
150
|
+
tray_icon::MouseButton::Middle => "middle",
|
|
151
|
+
};
|
|
152
|
+
let _ = hash.aset(sym(&ruby, "button"), sym(&ruby, btn_sym));
|
|
153
|
+
crate::tray::call_tray_event_handler(&ruby, hash);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
Event::WindowEvent {
|
|
158
|
+
event: ref win_event,
|
|
159
|
+
window_id,
|
|
160
|
+
..
|
|
161
|
+
} => {
|
|
162
|
+
let event_hash = convert_window_event(&ruby, win_event);
|
|
163
|
+
window_store::dispatch_event(&ruby, &window_id, event_hash);
|
|
164
|
+
}
|
|
165
|
+
Event::RedrawRequested(window_id) => {
|
|
166
|
+
let hash = ruby.hash_new();
|
|
167
|
+
let type_key: Symbol = ruby.sym_new("type").into();
|
|
168
|
+
let type_val: Symbol = ruby.sym_new("redraw_requested").into();
|
|
169
|
+
let _ = hash.aset(type_key, type_val);
|
|
170
|
+
window_store::dispatch_event(&ruby, &window_id, hash);
|
|
171
|
+
}
|
|
172
|
+
Event::LoopDestroyed => {
|
|
173
|
+
EVENT_LOOP_TARGET.with(|cell| {
|
|
174
|
+
*cell.borrow_mut() = None;
|
|
175
|
+
});
|
|
176
|
+
EXIT_PROXY.with(|cell| {
|
|
177
|
+
*cell.borrow_mut() = None;
|
|
178
|
+
});
|
|
179
|
+
APP_MENU_HANDLER.with(|cell| {
|
|
180
|
+
*cell.borrow_mut() = None;
|
|
181
|
+
});
|
|
182
|
+
window_store::clear_all();
|
|
183
|
+
crate::menu::clear_all();
|
|
184
|
+
crate::tray::clear_all();
|
|
185
|
+
crate::hotkey::clear_all();
|
|
186
|
+
crate::webview::clear_all();
|
|
187
|
+
crate::monitor::clear_video_modes();
|
|
188
|
+
}
|
|
189
|
+
_ => {}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fn ranma_app_exit() -> Result<(), Error> {
|
|
195
|
+
EXIT_PROXY.with(|cell| {
|
|
196
|
+
if let Some(proxy) = cell.borrow().as_ref() {
|
|
197
|
+
let _ = proxy.send_event(UserEvent::Exit);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
Ok(())
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
fn ranma_app_request_redraw() -> Result<(), Error> {
|
|
204
|
+
if let Some(proxy) = GLOBAL_PROXY.get() {
|
|
205
|
+
let _ = proxy.send_event(UserEvent::Redraw);
|
|
206
|
+
}
|
|
207
|
+
Ok(())
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn ranma_app_on_menu_event(handler: Value) -> Result<(), Error> {
|
|
211
|
+
gc::register_mark_object(handler);
|
|
212
|
+
APP_MENU_HANDLER.with(|cell| {
|
|
213
|
+
*cell.borrow_mut() = Some(handler);
|
|
214
|
+
});
|
|
215
|
+
Ok(())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fn ranma_app_on_tray_event(handler: Value) -> Result<(), Error> {
|
|
219
|
+
crate::tray::set_tray_event_handler(handler);
|
|
220
|
+
Ok(())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
pub fn define_app(ruby: &Ruby, module: &magnus::RModule) -> Result<(), Error> {
|
|
224
|
+
let app_class = module.define_class("App", ruby.class_object())?;
|
|
225
|
+
app_class.define_singleton_method("start", function!(ranma_app_start, 1))?;
|
|
226
|
+
app_class.define_singleton_method("exit", function!(ranma_app_exit, 0))?;
|
|
227
|
+
app_class.define_singleton_method("request_redraw", function!(ranma_app_request_redraw, 0))?;
|
|
228
|
+
app_class.define_singleton_method("on_menu_event", function!(ranma_app_on_menu_event, 1))?;
|
|
229
|
+
app_class.define_singleton_method("on_tray_event", function!(ranma_app_on_tray_event, 1))?;
|
|
230
|
+
Ok(())
|
|
231
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Fullscreen triangle blit shader:
|
|
2
|
+
// Copies Vello render_to_texture output (Rgba8Unorm) to surface texture.
|
|
3
|
+
|
|
4
|
+
@group(0) @binding(0)
|
|
5
|
+
var src_texture: texture_2d<f32>;
|
|
6
|
+
@group(0) @binding(1)
|
|
7
|
+
var src_sampler: sampler;
|
|
8
|
+
|
|
9
|
+
struct VertexOutput {
|
|
10
|
+
@builtin(position) position: vec4<f32>,
|
|
11
|
+
@location(0) uv: vec2<f32>,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
@vertex
|
|
15
|
+
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
|
16
|
+
// Fullscreen triangle: 3 vertices that cover the entire clip space [-1,1]
|
|
17
|
+
// vertex 0: (-1, -1), vertex 1: (3, -1), vertex 2: (-1, 3)
|
|
18
|
+
var out: VertexOutput;
|
|
19
|
+
let x = f32(i32(vertex_index & 1u)) * 4.0 - 1.0;
|
|
20
|
+
let y = f32(i32(vertex_index >> 1u)) * 4.0 - 1.0;
|
|
21
|
+
out.position = vec4<f32>(x, y, 0.0, 1.0);
|
|
22
|
+
out.uv = vec2<f32>(
|
|
23
|
+
(x + 1.0) * 0.5,
|
|
24
|
+
(1.0 - y) * 0.5,
|
|
25
|
+
);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@fragment
|
|
30
|
+
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
|
31
|
+
return textureSample(src_texture, src_sampler, in.uv);
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
use magnus::{function, prelude::*, Error, Ruby};
|
|
2
|
+
|
|
3
|
+
fn ranma_clipboard_get_text() -> Result<String, Error> {
|
|
4
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
5
|
+
let mut clipboard = arboard::Clipboard::new().map_err(|e| {
|
|
6
|
+
Error::new(
|
|
7
|
+
ruby.exception_runtime_error(),
|
|
8
|
+
format!("Failed to access clipboard: {}", e),
|
|
9
|
+
)
|
|
10
|
+
})?;
|
|
11
|
+
clipboard.get_text().map_err(|e| {
|
|
12
|
+
Error::new(
|
|
13
|
+
ruby.exception_runtime_error(),
|
|
14
|
+
format!("Failed to get clipboard text: {}", e),
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fn ranma_clipboard_set_text(text: String) -> Result<(), Error> {
|
|
20
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
21
|
+
let mut clipboard = arboard::Clipboard::new().map_err(|e| {
|
|
22
|
+
Error::new(
|
|
23
|
+
ruby.exception_runtime_error(),
|
|
24
|
+
format!("Failed to access clipboard: {}", e),
|
|
25
|
+
)
|
|
26
|
+
})?;
|
|
27
|
+
clipboard.set_text(text).map_err(|e| {
|
|
28
|
+
Error::new(
|
|
29
|
+
ruby.exception_runtime_error(),
|
|
30
|
+
format!("Failed to set clipboard text: {}", e),
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn ranma_clipboard_clear() -> Result<(), Error> {
|
|
36
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
37
|
+
let mut clipboard = arboard::Clipboard::new().map_err(|e| {
|
|
38
|
+
Error::new(
|
|
39
|
+
ruby.exception_runtime_error(),
|
|
40
|
+
format!("Failed to access clipboard: {}", e),
|
|
41
|
+
)
|
|
42
|
+
})?;
|
|
43
|
+
clipboard.clear().map_err(|e| {
|
|
44
|
+
Error::new(
|
|
45
|
+
ruby.exception_runtime_error(),
|
|
46
|
+
format!("Failed to clear clipboard: {}", e),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pub fn define_clipboard_class(ruby: &Ruby, module: &magnus::RModule) -> Result<(), Error> {
|
|
52
|
+
let class = module.define_class("Clipboard", ruby.class_object())?;
|
|
53
|
+
class.define_singleton_method("get_text", function!(ranma_clipboard_get_text, 0))?;
|
|
54
|
+
class.define_singleton_method("set_text", function!(ranma_clipboard_set_text, 1))?;
|
|
55
|
+
class.define_singleton_method("clear", function!(ranma_clipboard_clear, 0))?;
|
|
56
|
+
Ok(())
|
|
57
|
+
}
|