ruby_everywhere 0.1.1 → 0.1.3
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 +4 -4
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +42 -20
- data/lib/everywhere/commands/dev.rb +2 -1
- data/lib/everywhere/commands/icon.rb +62 -0
- data/lib/everywhere/icon.rb +159 -0
- data/lib/everywhere/paths.rb +13 -0
- data/lib/everywhere/png.rb +54 -0
- data/lib/everywhere/raster.rb +178 -0
- data/lib/everywhere/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6445e84081e08a1d5c545054c0a7dc6ef2e4b05338aff54ea68dc4c48d6b6c60
|
|
4
|
+
data.tar.gz: 2a057925b0ee3266c294046b0be24cb1c0d3fa4b8a82e179b88425cd6afa7e91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e8528cf2ac3b62191795709e23ee708d1c706672b8fc5d5f2beec6c3455d1a092b71498a086d67506e9c8175efcad1ad2f5c22df0b5d7dbe0f130a3aba8600e
|
|
7
|
+
data.tar.gz: ff1a73eeaabf7d7b839b092b3482c4a06511a6793133b6133f9671ad17d883092877b608fa8db04c9709a419d38c73f905e44dca32d952c992121a18c9e05aa1
|
data/lib/everywhere/cli.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "../everywhere"
|
|
|
5
5
|
require_relative "framework"
|
|
6
6
|
require_relative "commands/build"
|
|
7
7
|
require_relative "commands/dev"
|
|
8
|
+
require_relative "commands/icon"
|
|
8
9
|
require_relative "commands/doctor"
|
|
9
10
|
require_relative "commands/install"
|
|
10
11
|
require_relative "commands/release"
|
|
@@ -33,6 +34,7 @@ module Everywhere
|
|
|
33
34
|
register "install", Commands::Install
|
|
34
35
|
register "dev", Commands::Dev
|
|
35
36
|
register "build", Commands::Build
|
|
37
|
+
register "icon", Commands::Icon
|
|
36
38
|
register "release", Commands::Release
|
|
37
39
|
|
|
38
40
|
register "platform login", Commands::PlatformLogin
|
|
@@ -6,6 +6,7 @@ require "tmpdir"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require_relative "../shellout"
|
|
8
8
|
require_relative "../png"
|
|
9
|
+
require_relative "../icon"
|
|
9
10
|
require_relative "../paths"
|
|
10
11
|
|
|
11
12
|
module Everywhere
|
|
@@ -122,15 +123,40 @@ module Everywhere
|
|
|
122
123
|
end
|
|
123
124
|
UI.step("app icon: #{icon_source ? icon_source.sub("#{config.root}/", "") : "placeholder"}")
|
|
124
125
|
|
|
126
|
+
# macOS renders icons literally — no OS-applied rounding or margins — so
|
|
127
|
+
# a full-bleed square looks wrong next to native apps. Bake the Big Sur
|
|
128
|
+
# look (inset + squircle) into a shaped 1024px master and use it for
|
|
129
|
+
# both the compiled Dock icon and the bundle .icns. work/ is torn down
|
|
130
|
+
# when the .app is assembled.
|
|
131
|
+
work = Dir.mktmpdir("everywhere-icon")
|
|
132
|
+
macos_icon = icon_source
|
|
133
|
+
if icon_source
|
|
134
|
+
shaped = File.join(work, "AppIcon-macos.png")
|
|
135
|
+
if Everywhere::Icon.macos_master(icon_source, shaped)
|
|
136
|
+
macos_icon = shaped
|
|
137
|
+
else
|
|
138
|
+
UI.warn "couldn't read #{File.basename(icon_source)} (unusual PNG flavor) — using it unshaped"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
125
142
|
# Tauri bakes icons/icon.png into the shell binary and sets it as the
|
|
126
143
|
# Dock icon at runtime (stomping the bundle's icns after launch), so the
|
|
127
|
-
# shell must be COMPILED with the app's icon. Swap
|
|
128
|
-
# restore the placeholder after; touch build.rs so
|
|
144
|
+
# shell must be COMPILED with the app's icon. Swap in the shaped master
|
|
145
|
+
# for the build, restore the placeholder after; touch build.rs so
|
|
146
|
+
# tauri-build re-embeds.
|
|
129
147
|
shell_icon = File.join(shell_dir, "icons", "icon.png")
|
|
130
148
|
placeholder_backup = "#{shell_icon}.placeholder"
|
|
131
149
|
if icon_source
|
|
132
150
|
FileUtils.cp(shell_icon, placeholder_backup) unless File.exist?(placeholder_backup)
|
|
133
|
-
|
|
151
|
+
# The shaped master is already RGBA; fall back to raw RGBA conversion
|
|
152
|
+
# if shaping failed, and to the placeholder if even that fails.
|
|
153
|
+
dock_ok = if macos_icon == icon_source
|
|
154
|
+
Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
|
|
155
|
+
else
|
|
156
|
+
FileUtils.cp(macos_icon, shell_icon)
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
unless dock_ok
|
|
134
160
|
UI.warn "couldn't convert #{File.basename(icon_source)} to RGBA (unusual PNG flavor) — Dock icon will use the placeholder after launch"
|
|
135
161
|
FileUtils.cp(placeholder_backup, shell_icon)
|
|
136
162
|
end
|
|
@@ -138,17 +164,18 @@ module Everywhere
|
|
|
138
164
|
end
|
|
139
165
|
|
|
140
166
|
UI.step("building shell #{UI.dim("(cargo release)")}")
|
|
167
|
+
target_dir = Everywhere::Paths.cargo_target_dir
|
|
141
168
|
begin
|
|
142
|
-
Shellout.run!({}, "cargo", "build", "--release", chdir: shell_dir)
|
|
169
|
+
Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
|
|
143
170
|
ensure
|
|
144
171
|
if icon_source && File.exist?(placeholder_backup)
|
|
145
172
|
FileUtils.mv(placeholder_backup, shell_icon)
|
|
146
173
|
FileUtils.touch(File.join(shell_dir, "build.rs"))
|
|
147
174
|
end
|
|
148
175
|
end
|
|
149
|
-
shell_bin = Dir[File.join(
|
|
176
|
+
shell_bin = Dir[File.join(target_dir, "release/*")]
|
|
150
177
|
.find { |f| File.file?(f) && File.executable?(f) && !f.end_with?(".d") }
|
|
151
|
-
UI.die!("couldn't find release shell binary in #{
|
|
178
|
+
UI.die!("couldn't find release shell binary in #{target_dir}/release") unless shell_bin
|
|
152
179
|
|
|
153
180
|
app_dir = File.join(dist_dir, "#{app_name}.app")
|
|
154
181
|
macos = File.join(app_dir, "Contents", "MacOS")
|
|
@@ -170,7 +197,7 @@ module Everywhere
|
|
|
170
197
|
UI.warn "splash #{splash} not found — using the default splash"
|
|
171
198
|
end
|
|
172
199
|
end
|
|
173
|
-
icns = make_icns(
|
|
200
|
+
icns = make_icns(macos_icon || File.join(shell_dir, "icons", "icon.png"), resources)
|
|
174
201
|
File.write(File.join(app_dir, "Contents", "Info.plist"),
|
|
175
202
|
info_plist(app_name, exe_name, icns, config.bundle_id, config.version))
|
|
176
203
|
|
|
@@ -184,24 +211,19 @@ module Everywhere
|
|
|
184
211
|
Shellout.run?("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f #{app_dir.shellescape} 2>/dev/null")
|
|
185
212
|
|
|
186
213
|
UI.success("app bundle: #{app_dir.sub("#{Dir.pwd}/", "")} #{UI.dim("(open it!)")}")
|
|
214
|
+
ensure
|
|
215
|
+
FileUtils.remove_entry(work) if work && File.directory?(work)
|
|
187
216
|
end
|
|
188
217
|
|
|
189
|
-
#
|
|
218
|
+
# Write Resources/AppIcon.icns from a (shaped) PNG. Pure Ruby — no sips or
|
|
219
|
+
# iconutil — so it produces the same icns on any build host.
|
|
190
220
|
def make_icns(png, resources_dir)
|
|
191
221
|
return nil unless File.exist?(png)
|
|
192
222
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
[16, 32, 128, 256, 512].each do |size|
|
|
198
|
-
[[size, "icon_#{size}x#{size}.png"], [size * 2, "icon_#{size}x#{size}@2x.png"]].each do |px, name|
|
|
199
|
-
Shellout.run?("sips -z #{px} #{px} #{png.shellescape} --out #{File.join(iconset, name).shellescape} >/dev/null 2>&1")
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
out = File.join(resources_dir, "AppIcon.icns")
|
|
203
|
-
return "AppIcon" if Shellout.run?("iconutil -c icns #{iconset.shellescape} -o #{out.shellescape} >/dev/null 2>&1")
|
|
204
|
-
end
|
|
223
|
+
out = File.join(resources_dir, "AppIcon.icns")
|
|
224
|
+
Everywhere::Icon.write_icns(png, out) ? "AppIcon" : nil
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
UI.warn "icon generation failed (#{e.message}) — bundling without a custom icon"
|
|
205
227
|
nil
|
|
206
228
|
end
|
|
207
229
|
|
|
@@ -33,7 +33,8 @@ module Everywhere
|
|
|
33
33
|
|
|
34
34
|
dev_url = "http://127.0.0.1:#{port}#{config.entry_path}"
|
|
35
35
|
UI.step("opening desktop shell → #{UI.cyan(dev_url)}")
|
|
36
|
-
Shellout.run!({ "NATIVE_DEV_URL" => dev_url, "NATIVE_CONFIG" => config.to_shell_json
|
|
36
|
+
Shellout.run!({ "NATIVE_DEV_URL" => dev_url, "NATIVE_CONFIG" => config.to_shell_json,
|
|
37
|
+
"CARGO_TARGET_DIR" => Everywhere::Paths.cargo_target_dir },
|
|
37
38
|
"cargo", "run", chdir: shell_dir)
|
|
38
39
|
ensure
|
|
39
40
|
if server_pid
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../icon"
|
|
6
|
+
require_relative "../config"
|
|
7
|
+
require_relative "../ui"
|
|
8
|
+
|
|
9
|
+
module Everywhere
|
|
10
|
+
module Commands
|
|
11
|
+
# Generate platform-native app icons from a single source PNG — the same
|
|
12
|
+
# pipeline `every build` uses, exposed on its own for previewing and CI.
|
|
13
|
+
class Icon < Dry::CLI::Command
|
|
14
|
+
desc "Generate macOS (.icns), Windows (.ico) and Linux icons from a source PNG"
|
|
15
|
+
|
|
16
|
+
argument :source, required: false, desc: "Source PNG (default: app.icon / icon.png from everywhere.yml)"
|
|
17
|
+
option :root, default: ".", desc: "App root directory (to resolve the default icon)"
|
|
18
|
+
option :output, default: nil, desc: "Output directory (default: dist/icons)"
|
|
19
|
+
|
|
20
|
+
def call(source: nil, root: ".", output: nil, **)
|
|
21
|
+
root_path = File.expand_path(root)
|
|
22
|
+
source ||= default_source(root_path)
|
|
23
|
+
UI.die!("no source PNG — pass one or set app.icon in everywhere.yml") unless source
|
|
24
|
+
UI.die!("source PNG not found: #{source}") unless File.exist?(source)
|
|
25
|
+
|
|
26
|
+
out = File.expand_path(output || File.join(root_path, "dist", "icons"))
|
|
27
|
+
FileUtils.mkdir_p(out)
|
|
28
|
+
UI.step("generating icons from #{File.basename(source)} → #{out.sub("#{Dir.pwd}/", "")}")
|
|
29
|
+
|
|
30
|
+
master = File.join(out, "AppIcon-macos.png")
|
|
31
|
+
unless Everywhere::Icon.macos_master(source, master)
|
|
32
|
+
UI.die!("couldn't read #{File.basename(source)} — expected an 8-bit RGB/RGBA PNG")
|
|
33
|
+
end
|
|
34
|
+
UI.ok("macOS master #{rel(master, out)} #{UI.dim("(inset + squircle, 1024px)")}")
|
|
35
|
+
|
|
36
|
+
Everywhere::Icon.write_icns(master, File.join(out, "AppIcon.icns"))
|
|
37
|
+
UI.ok("macOS icon AppIcon.icns")
|
|
38
|
+
|
|
39
|
+
Everywhere::Icon.write_ico(source, File.join(out, "icon.ico"))
|
|
40
|
+
UI.ok("Windows icon icon.ico")
|
|
41
|
+
|
|
42
|
+
pngs = Everywhere::Icon.write_png_set(source, File.join(out, "linux"))
|
|
43
|
+
UI.ok("Linux icons linux/ #{UI.dim("(#{pngs.size} sizes)")}")
|
|
44
|
+
|
|
45
|
+
UI.success("icons written to #{out.sub("#{Dir.pwd}/", "")}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def rel(path, base)
|
|
51
|
+
path.sub("#{base}/", "")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_source(root_path)
|
|
55
|
+
Everywhere::Config.load(root_path).icon
|
|
56
|
+
rescue StandardError
|
|
57
|
+
default = File.join(root_path, "icon.png")
|
|
58
|
+
File.exist?(default) ? default : nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "raster"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# Turns one source PNG into platform-native app icons — no Tauri, no sips, no
|
|
8
|
+
# iconutil, no ImageMagick. Pure Ruby, so it runs the same locally and on the
|
|
9
|
+
# cloud build runners.
|
|
10
|
+
#
|
|
11
|
+
# The important part is macОS shaping. macOS renders an .icns *literally*: it
|
|
12
|
+
# does not round the corners or add margins the way iOS does. A full-bleed
|
|
13
|
+
# square PNG therefore shows up as a hard square, out of place next to every
|
|
14
|
+
# native app. So we bake the platform's look into the artwork ourselves —
|
|
15
|
+
# inset the icon to the Big Sur grid (~824px inside a 1024px canvas) and clip
|
|
16
|
+
# it to a superellipse ("squircle") with transparent corners.
|
|
17
|
+
#
|
|
18
|
+
# Windows (.ico) and Linux (hicolor PNGs) don't get the squircle — those
|
|
19
|
+
# desktops present square icons — so they're generated straight from the
|
|
20
|
+
# full-bleed source.
|
|
21
|
+
module Icon
|
|
22
|
+
# Big Sur icon-grid geometry, all as fractions of the 1024px canvas so it
|
|
23
|
+
# scales to any master size.
|
|
24
|
+
CANVAS = 1024
|
|
25
|
+
INSET = 0.8047 # icon body is 824/1024 of the canvas
|
|
26
|
+
CORNER_RATIO = 0.2237 # corner radius as a fraction of the body's side
|
|
27
|
+
EXPONENT = 5.0 # superellipse power — the iOS/macOS "squircle" corner
|
|
28
|
+
|
|
29
|
+
# .icns type codes -> pixel size. Mirrors what `iconutil` emits from a
|
|
30
|
+
# standard .iconset (retina variants included), so the result is
|
|
31
|
+
# indistinguishable from an Apple-toolchain icns.
|
|
32
|
+
ICNS_TYPES = [
|
|
33
|
+
["icp4", 16], ["icp5", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512],
|
|
34
|
+
["ic11", 32], ["ic12", 64], ["ic13", 256], ["ic14", 512], ["ic10", 1024]
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
ICO_SIZES = [16, 32, 48, 64, 128, 256].freeze
|
|
38
|
+
LINUX_SIZES = [16, 32, 48, 64, 128, 256, 512].freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Write a macOS-shaped 1024px RGBA master PNG (padding + squircle) from a
|
|
43
|
+
# source PNG. Returns true, or false if the source PNG can't be read.
|
|
44
|
+
def macos_master(source, dest, canvas: CANVAS)
|
|
45
|
+
raster = Raster.decode_png(source) or return false
|
|
46
|
+
|
|
47
|
+
body = (canvas * INSET).round
|
|
48
|
+
offset = (canvas - body) / 2
|
|
49
|
+
|
|
50
|
+
# Fit the artwork inside the body square preserving aspect ratio (square
|
|
51
|
+
# sources fill it exactly; non-square ones are centred, not stretched).
|
|
52
|
+
fit = [body.to_f / raster.width, body.to_f / raster.height].min
|
|
53
|
+
sw = (raster.width * fit).round.clamp(1, body)
|
|
54
|
+
sh = (raster.height * fit).round.clamp(1, body)
|
|
55
|
+
scaled = raster.resample(sw, sh)
|
|
56
|
+
|
|
57
|
+
out = Raster.transparent(canvas, canvas)
|
|
58
|
+
out.paste!(scaled, offset + (body - sw) / 2, offset + (body - sh) / 2)
|
|
59
|
+
|
|
60
|
+
centre = canvas / 2.0
|
|
61
|
+
half = body / 2.0
|
|
62
|
+
radius = [body * CORNER_RATIO, half].min
|
|
63
|
+
straight = half - radius # half-extent of the straight edges
|
|
64
|
+
out.mask_region!(offset, offset, offset + body, offset + body) do |x, y|
|
|
65
|
+
squircle_coverage(x, y, centre, half, straight, radius, EXPONENT)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
out.write_png(dest)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Build a macOS .icns from an already-shaped master PNG (ideally 1024px).
|
|
73
|
+
# Returns true on success.
|
|
74
|
+
def write_icns(master_png, dest)
|
|
75
|
+
master = Raster.decode_png(master_png) or return false
|
|
76
|
+
|
|
77
|
+
body = ICNS_TYPES.map do |type, size|
|
|
78
|
+
png = sized_png(master, size)
|
|
79
|
+
type.b + [png.bytesize + 8].pack("N") + png
|
|
80
|
+
end.join
|
|
81
|
+
|
|
82
|
+
File.binwrite(dest, "icns".b + [body.bytesize + 8].pack("N") + body)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Build a Windows .ico (PNG-compressed entries, Vista+) from a full-bleed
|
|
87
|
+
# source PNG. Returns true on success.
|
|
88
|
+
def write_ico(source, dest, sizes: ICO_SIZES)
|
|
89
|
+
master = Raster.decode_png(source) or return false
|
|
90
|
+
|
|
91
|
+
images = sizes.map { |s| [s, sized_png(master, s)] }
|
|
92
|
+
offset = 6 + images.size * 16
|
|
93
|
+
entries = +"".b
|
|
94
|
+
blob = +"".b
|
|
95
|
+
images.each do |size, png|
|
|
96
|
+
dim = size >= 256 ? 0 : size # 0 means 256 in the ICONDIRENTRY
|
|
97
|
+
entries << [dim, dim, 0, 0, 1, 32, png.bytesize, offset].pack("CCCCvvVV")
|
|
98
|
+
blob << png
|
|
99
|
+
offset += png.bytesize
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
header = [0, 1, images.size].pack("vvv") # reserved, type=icon, count
|
|
103
|
+
File.binwrite(dest, header + entries + blob)
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Write a set of square PNGs (Linux/desktop use) named "<size>x<size>.png"
|
|
108
|
+
# into dest_dir from a full-bleed source. Returns the written paths.
|
|
109
|
+
def write_png_set(source, dest_dir, sizes: LINUX_SIZES)
|
|
110
|
+
master = Raster.decode_png(source) or return []
|
|
111
|
+
|
|
112
|
+
FileUtils.mkdir_p(dest_dir)
|
|
113
|
+
sizes.map do |size|
|
|
114
|
+
path = File.join(dest_dir, "#{size}x#{size}.png")
|
|
115
|
+
File.binwrite(path, sized_png(master, size))
|
|
116
|
+
path
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# PNG bytes of `master` at `size`x`size` (no-op resample when already sized).
|
|
121
|
+
def sized_png(master, size)
|
|
122
|
+
(size == master.width && size == master.height ? master : master.resample(size, size)).encode_png
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Coverage (0.0..1.0) of pixel (x, y) by the squircle: a rounded rectangle
|
|
126
|
+
# with continuous (superelliptical) corners. Interior pixels return 1.0,
|
|
127
|
+
# exterior 0.0, and boundary pixels are antialiased by 4x4 supersampling.
|
|
128
|
+
def squircle_coverage(x, y, centre, half, straight, radius, exponent)
|
|
129
|
+
c0 = squircle_inside?(x, y, centre, half, straight, radius, exponent)
|
|
130
|
+
c1 = squircle_inside?(x + 1, y, centre, half, straight, radius, exponent)
|
|
131
|
+
c2 = squircle_inside?(x, y + 1, centre, half, straight, radius, exponent)
|
|
132
|
+
c3 = squircle_inside?(x + 1, y + 1, centre, half, straight, radius, exponent)
|
|
133
|
+
return 1.0 if c0 && c1 && c2 && c3
|
|
134
|
+
return 0.0 unless c0 || c1 || c2 || c3
|
|
135
|
+
|
|
136
|
+
hit = 0
|
|
137
|
+
4.times do |i|
|
|
138
|
+
4.times do |j|
|
|
139
|
+
hit += 1 if squircle_inside?(x + (i + 0.5) / 4.0, y + (j + 0.5) / 4.0,
|
|
140
|
+
centre, half, straight, radius, exponent)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
hit / 16.0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Is the continuous point (sx, sy) inside the squircle? Straight edges up to
|
|
147
|
+
# `straight` from centre, superelliptical corners of `radius` beyond that.
|
|
148
|
+
def squircle_inside?(sx, sy, centre, half, straight, radius, exponent)
|
|
149
|
+
u = (sx - centre).abs
|
|
150
|
+
v = (sy - centre).abs
|
|
151
|
+
return false if u > half || v > half
|
|
152
|
+
return true if u <= straight || v <= straight
|
|
153
|
+
|
|
154
|
+
du = (u - straight) / radius
|
|
155
|
+
dv = (v - straight) / radius
|
|
156
|
+
(du**exponent) + (dv**exponent) <= 1.0
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -31,5 +31,18 @@ module Everywhere
|
|
|
31
31
|
shell_dir or UI.die!("couldn't find the bundled shell at #{bundled_shell_dir}; " \
|
|
32
32
|
"reinstall ruby_everywhere or pass --shell-dir")
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
# Per-user cache dir. Keeps heavy build state out of the gem and the app.
|
|
36
|
+
def cache_dir
|
|
37
|
+
File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Where cargo writes the shell's build output (CARGO_TARGET_DIR). Redirected
|
|
41
|
+
# here so cargo never dumps a multi-GB target/ into the installed gem, and so
|
|
42
|
+
# the shell compiles warm across projects — the shell source is identical for
|
|
43
|
+
# everyone, so one shared target dir is safe to reuse.
|
|
44
|
+
def cargo_target_dir
|
|
45
|
+
File.join(cache_dir, "shell-target")
|
|
46
|
+
end
|
|
34
47
|
end
|
|
35
48
|
end
|
data/lib/everywhere/png.rb
CHANGED
|
@@ -39,6 +39,60 @@ module Everywhere
|
|
|
39
39
|
true
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Decode an 8-bit RGB/RGBA PNG to a flat RGBA pixel buffer. Returns
|
|
43
|
+
# [width, height, bytes] (bytes = width*height*4, no per-scanline filter
|
|
44
|
+
# byte) or nil for flavors we don't handle (palette, 16-bit, interlaced,
|
|
45
|
+
# grayscale). This is the pixel gateway the Raster/Icon generators build on.
|
|
46
|
+
def decode_rgba(path)
|
|
47
|
+
data = File.binread(path)
|
|
48
|
+
return nil unless data.start_with?(SIGNATURE)
|
|
49
|
+
|
|
50
|
+
chunks = read_chunks(data)
|
|
51
|
+
ihdr = chunks.find { |type, _| type == "IHDR" }&.last or return nil
|
|
52
|
+
width, height, depth, color, _comp, _filter, interlace = ihdr.unpack("NNC5")
|
|
53
|
+
return nil unless depth == 8 && interlace.zero?
|
|
54
|
+
return nil unless color == 2 || color == 6 # RGB or RGBA
|
|
55
|
+
|
|
56
|
+
bpp = color == 6 ? 4 : 3
|
|
57
|
+
raw = Zlib::Inflate.inflate(chunks.select { |t, _| t == "IDAT" }.map(&:last).join)
|
|
58
|
+
stride = width * bpp
|
|
59
|
+
prev = "\0".b * stride
|
|
60
|
+
out = String.new(capacity: width * height * 4)
|
|
61
|
+
|
|
62
|
+
height.times do |y|
|
|
63
|
+
filter = raw.getbyte(y * (stride + 1))
|
|
64
|
+
line = raw.byteslice(y * (stride + 1) + 1, stride).dup
|
|
65
|
+
unfilter!(line, prev, filter, bpp)
|
|
66
|
+
if bpp == 4
|
|
67
|
+
out << line
|
|
68
|
+
else
|
|
69
|
+
x = 0
|
|
70
|
+
while x < width
|
|
71
|
+
out << line.byteslice(x * 3, 3) << "\xFF".b
|
|
72
|
+
x += 1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
prev = line
|
|
76
|
+
end
|
|
77
|
+
[width, height, out]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Encode a flat RGBA pixel buffer to PNG bytes (filter 0, single IDAT).
|
|
81
|
+
def encode_rgba(width, height, rgba)
|
|
82
|
+
stride = width * 4
|
|
83
|
+
raw = String.new(capacity: (stride + 1) * height)
|
|
84
|
+
height.times do |y|
|
|
85
|
+
raw << "\0".b # filter: None
|
|
86
|
+
raw << rgba.byteslice(y * stride, stride)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
out = SIGNATURE.dup
|
|
90
|
+
out << chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5"))
|
|
91
|
+
out << chunk("IDAT", Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION))
|
|
92
|
+
out << chunk("IEND", "")
|
|
93
|
+
out
|
|
94
|
+
end
|
|
95
|
+
|
|
42
96
|
def read_chunks(data)
|
|
43
97
|
chunks = []
|
|
44
98
|
pos = 8
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "png"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# A mutable RGBA raster and the handful of pixel operations the icon
|
|
7
|
+
# generator needs — decode/encode PNG, high-quality resampling, pasting, and
|
|
8
|
+
# a superellipse ("squircle") alpha mask. Pure Ruby (zlib only): no
|
|
9
|
+
# ImageMagick, no sips, no Tauri. Works the same on any OS the CLI runs on.
|
|
10
|
+
#
|
|
11
|
+
# Pixels are stored as a flat 8-bit RGBA byte string (row-major, 4 bytes per
|
|
12
|
+
# pixel). Resampling is a separable area/box filter done in *premultiplied*
|
|
13
|
+
# alpha so transparent regions never bleed their colour into opaque edges.
|
|
14
|
+
class Raster
|
|
15
|
+
attr_reader :width, :height, :pixels
|
|
16
|
+
|
|
17
|
+
def initialize(width, height, pixels = nil)
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
@pixels = pixels || ("\x00".b * (width * height * 4))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A fully transparent canvas.
|
|
24
|
+
def self.transparent(width, height)
|
|
25
|
+
new(width, height)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Decode a PNG file into a Raster, or nil if it's a flavor PNG.decode_rgba
|
|
29
|
+
# can't read (palette, 16-bit, interlaced, grayscale).
|
|
30
|
+
def self.decode_png(path)
|
|
31
|
+
width, height, bytes = PNG.decode_rgba(path)
|
|
32
|
+
return nil unless width
|
|
33
|
+
|
|
34
|
+
new(width, height, bytes)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def encode_png
|
|
38
|
+
PNG.encode_rgba(width, height, pixels)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_png(path)
|
|
42
|
+
File.binwrite(path, encode_png)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Resample to a new size with a separable area filter. Great for the
|
|
46
|
+
# downscales an icon set is made of; degrades gracefully on upscale.
|
|
47
|
+
def resample(new_width, new_height)
|
|
48
|
+
return self if new_width == width && new_height == height
|
|
49
|
+
|
|
50
|
+
# Premultiplied float channels: pr = r * (a/255), etc.
|
|
51
|
+
pr = Array.new(width * height)
|
|
52
|
+
pg = Array.new(width * height)
|
|
53
|
+
pb = Array.new(width * height)
|
|
54
|
+
pa = Array.new(width * height)
|
|
55
|
+
i = 0
|
|
56
|
+
(width * height).times do |p|
|
|
57
|
+
a = pixels.getbyte(i + 3)
|
|
58
|
+
af = a / 255.0
|
|
59
|
+
pr[p] = pixels.getbyte(i) * af
|
|
60
|
+
pg[p] = pixels.getbyte(i + 1) * af
|
|
61
|
+
pb[p] = pixels.getbyte(i + 2) * af
|
|
62
|
+
pa[p] = a.to_f
|
|
63
|
+
i += 4
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Horizontal pass (width -> new_width), then vertical (height -> new_height).
|
|
67
|
+
wx = self.class.axis_weights(width, new_width)
|
|
68
|
+
hr = resample_axis(pr, width, height, new_width, wx, rows: true)
|
|
69
|
+
hg = resample_axis(pg, width, height, new_width, wx, rows: true)
|
|
70
|
+
hb = resample_axis(pb, width, height, new_width, wx, rows: true)
|
|
71
|
+
ha = resample_axis(pa, width, height, new_width, wx, rows: true)
|
|
72
|
+
|
|
73
|
+
wy = self.class.axis_weights(height, new_height)
|
|
74
|
+
vr = resample_axis(hr, new_width, height, new_height, wy, rows: false)
|
|
75
|
+
vg = resample_axis(hg, new_width, height, new_height, wy, rows: false)
|
|
76
|
+
vb = resample_axis(hb, new_width, height, new_height, wy, rows: false)
|
|
77
|
+
va = resample_axis(ha, new_width, height, new_height, wy, rows: false)
|
|
78
|
+
|
|
79
|
+
out = String.new(capacity: new_width * new_height * 4)
|
|
80
|
+
(new_width * new_height).times do |p|
|
|
81
|
+
a = va[p]
|
|
82
|
+
if a <= 0.001
|
|
83
|
+
out << "\x00\x00\x00\x00".b
|
|
84
|
+
else
|
|
85
|
+
out << [
|
|
86
|
+
(vr[p] * 255.0 / a).round.clamp(0, 255),
|
|
87
|
+
(vg[p] * 255.0 / a).round.clamp(0, 255),
|
|
88
|
+
(vb[p] * 255.0 / a).round.clamp(0, 255),
|
|
89
|
+
a.round.clamp(0, 255)
|
|
90
|
+
].pack("C4")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
self.class.new(new_width, new_height, out)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Copy src into this raster with its top-left at (ox, oy), replacing pixels
|
|
97
|
+
# (the canvas is transparent underneath, so a plain copy is the composite).
|
|
98
|
+
def paste!(src, ox, oy)
|
|
99
|
+
row = src.width * 4
|
|
100
|
+
src.height.times do |y|
|
|
101
|
+
dst_index = ((oy + y) * width + ox) * 4
|
|
102
|
+
pixels[dst_index, row] = src.pixels.byteslice(y * row, row)
|
|
103
|
+
end
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Multiply the alpha channel of every pixel in [x0, x1) x [y0, y1) by the
|
|
108
|
+
# coverage the block returns for pixel (x, y) — a value in 0.0..1.0.
|
|
109
|
+
# Pixels outside the range keep their (already transparent) alpha.
|
|
110
|
+
def mask_region!(x0, y0, x1, y1)
|
|
111
|
+
y0.upto(y1 - 1) do |y|
|
|
112
|
+
base = y * width
|
|
113
|
+
x0.upto(x1 - 1) do |x|
|
|
114
|
+
cov = yield(x, y)
|
|
115
|
+
next if cov >= 1.0
|
|
116
|
+
|
|
117
|
+
idx = (base + x) * 4 + 3
|
|
118
|
+
a = pixels.getbyte(idx)
|
|
119
|
+
next if a.zero?
|
|
120
|
+
|
|
121
|
+
pixels.setbyte(idx, cov <= 0.0 ? 0 : (a * cov).round)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# For each output index along an axis of `n_out` pixels, the list of
|
|
128
|
+
# [input_index, weight] contributions, where weights sum to 1 (area filter:
|
|
129
|
+
# each output pixel is the average of the input pixels it overlaps).
|
|
130
|
+
def self.axis_weights(n_in, n_out)
|
|
131
|
+
scale = n_in.to_f / n_out
|
|
132
|
+
Array.new(n_out) do |i|
|
|
133
|
+
start = i * scale
|
|
134
|
+
finish = [start + scale, n_in.to_f].min # float slop can push this past n_in
|
|
135
|
+
weights = []
|
|
136
|
+
k = [start.floor, 0].max
|
|
137
|
+
while k < finish && k < n_in
|
|
138
|
+
lo = [k.to_f, start].max
|
|
139
|
+
hi = [k + 1.0, finish].min
|
|
140
|
+
overlap = hi - lo
|
|
141
|
+
weights << [k, overlap / scale] if overlap > 0
|
|
142
|
+
k += 1
|
|
143
|
+
end
|
|
144
|
+
weights
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Resample one float channel along a single axis. `rows: true` resizes each
|
|
151
|
+
# row from `n_in` to `n_out` columns; `rows: false` resizes each column.
|
|
152
|
+
def resample_axis(src, in_w, in_h, n_out, weights, rows:)
|
|
153
|
+
if rows
|
|
154
|
+
out = Array.new(n_out * in_h, 0.0)
|
|
155
|
+
in_h.times do |y|
|
|
156
|
+
src_base = y * in_w
|
|
157
|
+
out_base = y * n_out
|
|
158
|
+
n_out.times do |o|
|
|
159
|
+
acc = 0.0
|
|
160
|
+
weights[o].each { |k, wt| acc += src[src_base + k] * wt }
|
|
161
|
+
out[out_base + o] = acc
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
out
|
|
165
|
+
else
|
|
166
|
+
out = Array.new(in_w * n_out, 0.0)
|
|
167
|
+
in_w.times do |x|
|
|
168
|
+
n_out.times do |o|
|
|
169
|
+
acc = 0.0
|
|
170
|
+
weights[o].each { |k, wt| acc += src[k * in_w + x] * wt }
|
|
171
|
+
out[o * in_w + x] = acc
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
out
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/everywhere/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_everywhere
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrea Fomera
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- lib/everywhere/commands/build.rb
|
|
56
56
|
- lib/everywhere/commands/dev.rb
|
|
57
57
|
- lib/everywhere/commands/doctor.rb
|
|
58
|
+
- lib/everywhere/commands/icon.rb
|
|
58
59
|
- lib/everywhere/commands/install.rb
|
|
59
60
|
- lib/everywhere/commands/platform/auth_status.rb
|
|
60
61
|
- lib/everywhere/commands/platform/build.rb
|
|
@@ -65,6 +66,7 @@ files:
|
|
|
65
66
|
- lib/everywhere/config.rb
|
|
66
67
|
- lib/everywhere/database.rb
|
|
67
68
|
- lib/everywhere/framework.rb
|
|
69
|
+
- lib/everywhere/icon.rb
|
|
68
70
|
- lib/everywhere/ignore.rb
|
|
69
71
|
- lib/everywhere/javascript/bridge.js
|
|
70
72
|
- lib/everywhere/paths.rb
|
|
@@ -72,6 +74,7 @@ files:
|
|
|
72
74
|
- lib/everywhere/platform/credentials.rb
|
|
73
75
|
- lib/everywhere/platform/snapshot.rb
|
|
74
76
|
- lib/everywhere/png.rb
|
|
77
|
+
- lib/everywhere/raster.rb
|
|
75
78
|
- lib/everywhere/receipt.rb
|
|
76
79
|
- lib/everywhere/shellout.rb
|
|
77
80
|
- lib/everywhere/ui.rb
|