Web mirror, license, notices, README, compat

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:04:30 -04:00
1 parent 6c5d482aa5
commit 1704e2a619
9 files changed
+803 -1

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
*.got.png
.DS_Store
/gallery
/.claude/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MujicaUI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+86
View File
@@ -0,0 +1,86 @@
# MujicaUI
宫廷哥特风格的 Go 桌面组件库,建立在 [MyGo](https://mygo.egoist.dev/docs/ui) 原生 UI 之上。象牙白与炭黑底色,酒红主色,旧金装饰线,衬线标题。100 个组件,浅深两套主题,三种密度。
界面由 MyGo 在 GPU 上自绘,不使用 HTML、JavaScript 或 WebView。
## 安装
模块路径暂为 `mujicaui`,尚未发布到代码托管地址。本地使用:
```sh
go mod edit -require=mujicaui@v0.0.0 -replace=mujicaui=/path/to/MujicaUI
```
要求 Go 1.27.1 与 MyGo v0.2.10(见 [兼容表](docs/compat.md))。
## 最小示例
```go
package main
import (
"log"
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
type app struct{ name string }
func (a *app) view(c *ui.Context) {
mujica.Use(c, mujica.Settings{Locale: mujica.ZhCN})
ui.Column(c).Fill().Center().Gap(12).Children(func() {
mujica.TextInput(c, &a.name, mujica.TextInputOptions{Placeholder: "姓名"})
if mujica.Button(c, "觐见", mujica.ButtonOptions{}).Clicked() {
log.Println("hello", a.name)
}
})
}
func main() {
a := &app{}
mygo.App.WhenReady(func() {
mygo.NewWindow(mygo.WindowOptions{Title: "Mujica", Width: 480, Height: 320, Content: ui.View(a.view)})
})
if err := mygo.App.Run(); err != nil {
log.Fatal(err)
}
}
```
## 主题
每帧开头调用 `mujica.Use(c, settings)`。`Settings` 决定模式(`System`、`Light`、`Dark`)、密度(`theme.Compact`、`theme.Default`、`theme.Comfortable`)、语言与文案覆盖。每个窗口各自设置,切换不重置业务状态。
```go
mujica.Use(c, mujica.Settings{
Mode: mujica.Dark,
Density: theme.Compact,
Locale: mujica.ZhCN,
})
k := mujica.Tokens(c) // 当前窗口的颜色
```
覆盖顺序:组件参数,窗口 `Settings`,库默认值。系统高对比度、文本缩放、减少动画始终生效。
## Gallery
```sh
go run ./examples/gallery # 原生窗口
go run ./examples/gallery -web :8080 # 浏览器镜像
```
浏览器镜像不在浏览器里运行组件。Go 进程用 `ui.NewTester` 驱动同一个 Gallery 视图,把帧推给页面,再把指针、键盘、输入法和剪贴板事件送回 Go。
## 文档
- [组件文档](docs/components/):每个组件一页。
- [兼容与平台依赖](docs/compat.md)
- [上游归并](docs/upstream.md)
## 许可
MIT。第三方声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
+62
View File
@@ -0,0 +1,62 @@
# Third-party notices
## Lucide icons 1.52.0 (icons/svg)
Copied unmodified except for the removed license comment line. Source: https://lucide.dev
```text
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Go module dependencies
Linked, not copied. Licenses ship with each module.
| Module | Version | License |
| --- | --- | --- |
| github.com/egoist/mygo | v0.2.10 | MIT |
| github.com/ebitengine/purego | v0.11.1 | Apache-2.0 |
| github.com/go-text/typesetting | v0.3.5 | BSD-3-Clause OR Unlicense |
| golang.org/x/image | v0.46.0 | BSD-3-Clause |
+25
View File
@@ -0,0 +1,25 @@
# 兼容与平台依赖
## 版本
| 项 | 值 |
| --- | --- |
| Go | 1.27.1 及以上(MyGo v0.2.10 的 go.mod 要求) |
| MyGo | v0.2.10,commit 459b512,固定在 go.mod 与 go.sum |
| 运行时依赖 | macOS:系统框架。Windows:无需 WebView2。Linux:GTK 3(MyGo 通过 GtkGLArea 绘制) |
## 已实测环境
| 系统 | 架构 | 会话 | 结果 |
| --- | --- | --- | --- |
| macOS(Darwin 27.2) | arm64 | Aqua | 无窗口测试、快照、Gallery 构建 |
以下项目尚未实测,不宣称支持:Windows(x64、arm64)、Linux X11、原生 Wayland、XWayland;VoiceOver、Narrator、NVDA、Orca;跨缩放显示器移动窗口;100% 以外的真机显示缩放。无窗口测试已覆盖 1x 与 2x 渲染,不替代真机。
## 快照基准
快照按系统分目录:`testdata/snapshots/<GOOS>/`。目前只有 `darwin` 基准。其他平台须在该平台运行 `go test -update .` 生成并人工检查后提交;在此之前用 `go test -skip Snapshot ./...` 运行其余测试。
## 离线
库与 Gallery 不发起网络请求,无遥测、账号或远程字体。图标与角饰随代码嵌入。浏览器镜像只在本机监听调用方给定的地址。
+8 -1
View File
@@ -1,9 +1,11 @@
// Gallery tours every MujicaUI component in a native MyGo window.
//
// go run ./examples/gallery
// go run ./examples/gallery native window
// go run ./examples/gallery -web :8080 browser mirror
package main
import (
"flag"
"log"
"github.com/egoist/mygo"
@@ -11,7 +13,12 @@ import (
)
func main() {
web := flag.String("web", "", "serve the gallery to a browser at this address instead of opening a window")
flag.Parse()
app := newGallery()
if *web != "" {
log.Fatal(serveWeb(*web, app.view))
}
mygo.App.WhenReady(func() {
mygo.NewWindow(mygo.WindowOptions{
Title: "MujicaUI Gallery",
+284
View File
@@ -0,0 +1,284 @@
package main
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"hash/maphash"
"image/png"
"log/slog"
"net/http"
"sync"
"time"
"github.com/egoist/mygo/ui"
)
//go:embed web/index.html
var indexHTML []byte
// mirror runs the gallery view headless and streams its frames.
type mirror struct {
mu sync.Mutex
tt *ui.Tester
frame []byte
seq int
hash uint64
seed maphash.Seed
subs map[chan []byte]struct{}
wake chan struct{}
urls int
told int
clip string
enc png.Encoder
}
func serveWeb(addr string, view func(c *ui.Context)) error {
m := &mirror{
tt: ui.NewTester(view, 1200, 800),
seed: maphash.MakeSeed(),
subs: map[chan []byte]struct{}{},
wake: make(chan struct{}, 1),
enc: png.Encoder{CompressionLevel: png.BestSpeed},
}
m.capture()
go m.loop()
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
})
mux.HandleFunc("GET /frame.png", m.serveFrame)
mux.HandleFunc("GET /events", m.serveEvents)
mux.HandleFunc("POST /input", m.serveInput)
slog.Info("gallery: web mirror listening", "addr", "http://"+addr)
return http.ListenAndServe(addr, mux)
}
// loop draws frames while the picture keeps changing, then sleeps.
func (m *mirror) loop() {
for range m.wake {
for still := 0; still < 6; {
time.Sleep(33 * time.Millisecond)
m.mu.Lock()
m.tt.Frame()
changed := m.capture()
m.mu.Unlock()
if changed {
still = 0
} else {
still++
}
}
}
}
func (m *mirror) kick() {
select {
case m.wake <- struct{}{}:
default:
}
}
// capture encodes the frame and notifies browsers; the caller holds mu.
func (m *mirror) capture() bool {
img := m.tt.Image()
h := maphash.Bytes(m.seed, img.Pix)
notice := m.notices()
if h == m.hash && notice == nil {
return false
}
if h != m.hash {
var b bytes.Buffer
if err := m.enc.Encode(&b, img); err != nil {
panic(err)
}
m.frame, m.hash = b.Bytes(), h
m.seq++
}
if notice == nil {
notice = map[string]any{}
}
notice["seq"] = m.seq
notice["cursor"] = cursorCSS(m.tt.Cursor())
msg, err := json.Marshal(notice)
if err != nil {
panic(err)
}
for ch := range m.subs {
select {
case ch <- msg:
default:
slog.Warn("gallery: web client lagging, frame notice dropped", "seq", m.seq)
}
}
return true
}
// notices gathers opened URLs, announcements and clipboard changes.
func (m *mirror) notices() map[string]any {
n := map[string]any{}
if urls := m.tt.OpenedURLs(); len(urls) > m.urls {
n["open"], m.urls = urls[m.urls:], len(urls)
}
if told := m.tt.Announcements(); len(told) > m.told {
n["announce"], m.told = told[m.told:], len(told)
}
if clip := m.tt.Clipboard(); clip != m.clip {
n["clipboard"], m.clip = clip, clip
}
if len(n) == 0 {
return nil
}
return n
}
func (m *mirror) serveFrame(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
frame := m.frame
m.mu.Unlock()
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
w.Write(frame)
}
func (m *mirror) serveEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
ch := make(chan []byte, 8)
m.mu.Lock()
m.subs[ch] = struct{}{}
first := fmt.Sprintf(`{"seq":%d}`, m.seq)
m.mu.Unlock()
slog.Info("gallery: web client connected", "remote", r.RemoteAddr)
defer func() {
m.mu.Lock()
delete(m.subs, ch)
m.mu.Unlock()
slog.Info("gallery: web client left", "remote", r.RemoteAddr)
}()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, "data: %s\n\n", first)
flusher.Flush()
for {
select {
case msg := <-ch:
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
// input is one browser event.
type input struct {
Type string `json:"type"`
X float32 `json:"x"`
Y float32 `json:"y"`
DX float32 `json:"dx"`
DY float32 `json:"dy"`
Button int `json:"button"`
Code string `json:"code"`
Text string `json:"text"`
Caret int `json:"caret"`
Mods []bool `json:"mods"`
W int `json:"w"`
H int `json:"h"`
Scale float32 `json:"scale"`
Dark bool `json:"dark"`
Motion bool `json:"reduceMotion"`
More bool `json:"moreContrast"`
}
func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) {
var in input
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
m.mu.Lock()
err := m.apply(in)
if err == nil {
m.capture()
}
m.mu.Unlock()
if err != nil {
slog.Warn("gallery: web input rejected", "type", in.Type, "err", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
m.kick()
w.WriteHeader(http.StatusNoContent)
}
// apply sends one event to the tester; the caller holds mu.
func (m *mirror) apply(in input) error {
mods := modifiers(in.Mods)
switch in.Type {
case "down":
if in.Button == 2 {
m.tt.RightClickAt(in.X, in.Y)
} else if mods != 0 {
m.tt.ClickAtWith(mods, in.X, in.Y)
} else {
m.tt.Press(in.X, in.Y)
}
case "up":
if in.Button == 0 && mods == 0 {
m.tt.Release(in.X, in.Y)
}
case "move":
m.tt.Move(in.X, in.Y)
case "wheel":
m.tt.Scroll(in.X, in.Y, in.DX, in.DY)
case "key":
k, ok := keyCodes[in.Code]
if !ok {
return fmt.Errorf("unmapped key code %q", in.Code)
}
if in.Text != "" && mods&^ui.Shift == 0 {
m.tt.TypeKey(mods, k, in.Text)
} else {
m.tt.Key(mods, k)
}
case "text":
m.tt.Type(in.Text)
case "compose":
m.tt.Compose(in.Text, in.Caret)
case "paste":
m.tt.SetClipboard(in.Text)
m.clip = in.Text
m.tt.Command("paste")
case "command":
m.tt.Command(in.Text)
case "resize":
if in.W < 320 || in.H < 240 || in.Scale <= 0 {
return fmt.Errorf("bad size %dx%d@%v", in.W, in.H, in.Scale)
}
m.tt.SetSize(in.W, in.H)
m.tt.SetScale(in.Scale)
m.tt.SetDark(in.Dark)
m.tt.SetPreferences(ui.Preferences{ReduceMotion: in.Motion, HighContrast: in.More, TextScale: 1})
case "focus":
m.tt.SetFocused(in.Text == "in")
default:
return fmt.Errorf("unknown input type %q", in.Type)
}
return nil
}
func modifiers(m []bool) ui.Modifiers {
var out ui.Modifiers
for i, mod := range []ui.Modifiers{ui.Shift, ui.Ctrl, ui.Alt, ui.Super} {
if i < len(m) && m[i] {
out |= mod
}
}
return out
}
+178
View File
@@ -0,0 +1,178 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MujicaUI Mirror</title>
<style>
:root {
--bg: #F2EBDD; --surface: #FBF7EF; --text: #211C1D; --muted: #6A5B55;
--border: #D2C3AF; --accent: #7D2034; --ornament: #AD9164; --ok: #326247; --bad: #A12635;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #191618; --surface: #242024; --text: #F2EBDD; --muted: #BCAEAA;
--border: #51424A; --accent: #E4A0AE; --ornament: #BDA172; --ok: #94C6A4; --bad: #F0A1AA;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); }
body { display: grid; grid-template-rows: auto 1fr auto; font: 13px/1.5 system-ui, sans-serif; }
header {
display: flex; align-items: center; gap: 16px; padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.mark { width: 9px; height: 9px; background: var(--ornament); transform: rotate(45deg); flex: none; }
h1 { margin: 0; font: 600 18px/1 Georgia, "Songti SC", serif; letter-spacing: .04em; }
.tag { color: var(--muted); }
.stats { margin-left: auto; display: flex; gap: 14px; color: var(--muted); font: 12px ui-monospace, monospace; }
.dot { display: inline-block; width: 7px; height: 7px; transform: rotate(45deg); margin-right: 6px; background: var(--bad); }
.live .dot { background: var(--ok); }
main { position: relative; min-height: 0; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; outline: none; touch-action: none; }
#keys { position: absolute; left: -9999px; top: 0; width: 1px; height: 1px; opacity: 0; }
footer {
padding: 6px 16px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px;
display: flex; gap: 16px; flex-wrap: wrap;
}
footer b { color: var(--text); font-weight: 600; }
#error { color: var(--bad); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
@media (max-width: 640px) { .tag, .stats .hide { display: none; } }
</style>
</head>
<body>
<header>
<span class="mark" aria-hidden="true"></span>
<h1>MujicaUI</h1>
<span class="tag">Go components, drawn by MyGo, mirrored here</span>
<div class="stats" aria-hidden="true">
<span id="link"><span class="dot"></span><span id="linktext">connecting</span></span>
<span class="hide">frame <b id="seq">0</b></span>
<span class="hide">input→pixels <b id="lat">–</b></span>
</div>
</header>
<main>
<canvas id="screen" tabindex="0" aria-label="MujicaUI Gallery, rendered by the Go server"></canvas>
<textarea id="keys" aria-hidden="true" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
</main>
<footer>
<span>The browser runs no component code. Every pixel comes from <b>ui.NewTester</b> in the Go process; your pointer, keys, IME and clipboard travel back to it.</span>
<span id="error" role="alert"></span>
</footer>
<div id="say" class="sr" aria-live="polite"></div>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
const canvas = $("screen"), ctx = canvas.getContext("2d"), keys = $("keys");
let shown = 0, wanted = 0, loading = false, sentAt = 0, queue = Promise.resolve();
function fail(msg) { $("error").textContent = msg; console.error("[mirror]", msg); }
function send(ev) {
if (ev.type !== "move") sentAt = performance.now();
queue = queue.then(() => fetch("/input", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(ev),
})).then(async (r) => { if (!r.ok) fail(`input ${ev.type}: ${r.status} ${await r.text()}`); })
.catch((e) => fail(`input ${ev.type}: ${e}`));
}
function mods(e) { return [e.shiftKey, e.ctrlKey, e.altKey, e.metaKey]; }
function fetchFrame() {
if (loading || shown === wanted) return;
loading = true;
const seq = wanted, img = new Image();
img.onload = () => {
canvas.width = img.width; canvas.height = img.height;
ctx.drawImage(img, 0, 0);
shown = seq; loading = false; $("seq").textContent = seq;
if (sentAt) { $("lat").textContent = Math.round(performance.now() - sentAt) + " ms"; sentAt = 0; }
fetchFrame();
};
img.onerror = () => { loading = false; fail(`frame ${seq} failed to load`); };
img.src = `/frame.png?s=${seq}`;
}
function connect() {
const es = new EventSource("/events");
es.onopen = () => { $("link").className = "live"; $("linktext").textContent = "live"; resize(); };
es.onerror = () => { $("link").className = ""; $("linktext").textContent = "reconnecting"; };
es.onmessage = (m) => {
const n = JSON.parse(m.data);
if (n.cursor) canvas.style.cursor = n.cursor;
for (const url of n.open || []) window.open(url, "_blank", "noopener");
for (const line of n.announce || []) $("say").textContent = line;
if (typeof n.clipboard === "string") {
navigator.clipboard.writeText(n.clipboard).catch((e) => fail(`clipboard write: ${e.message}`));
}
wanted = n.seq; fetchFrame();
};
}
const media = {
dark: matchMedia("(prefers-color-scheme: dark)"),
motion: matchMedia("(prefers-reduced-motion: reduce)"),
contrast: matchMedia("(prefers-contrast: more)"),
};
function resize() {
const r = canvas.getBoundingClientRect();
send({
type: "resize", w: Math.max(320, Math.round(r.width)), h: Math.max(240, Math.round(r.height)),
scale: Math.min(window.devicePixelRatio || 1, 2), dark: media.dark.matches,
reduceMotion: media.motion.matches, moreContrast: media.contrast.matches,
});
}
new ResizeObserver(() => { clearTimeout(resize.t); resize.t = setTimeout(resize, 80); }).observe(canvas);
Object.values(media).forEach((q) => q.addEventListener("change", resize));
function at(e) { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
canvas.addEventListener("pointerdown", (e) => {
canvas.setPointerCapture(e.pointerId); keys.focus({ preventScroll: true });
send({ type: "down", button: e.button, mods: mods(e), ...at(e) }); e.preventDefault();
});
canvas.addEventListener("pointerup", (e) => send({ type: "up", button: e.button, mods: mods(e), ...at(e) }));
let move = null;
canvas.addEventListener("pointermove", (e) => {
const first = move === null; move = at(e);
if (first) requestAnimationFrame(() => { send({ type: "move", ...move }); move = null; });
});
canvas.addEventListener("wheel", (e) => {
const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? canvas.clientHeight : 1;
send({ type: "wheel", dx: -e.deltaX * k, dy: -e.deltaY * k, ...at(e) }); e.preventDefault();
}, { passive: false });
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
canvas.addEventListener("focus", () => keys.focus({ preventScroll: true }));
const commands = { KeyC: "copy", KeyX: "cut", KeyA: "selectAll", KeyZ: "undo" };
let composing = false;
keys.addEventListener("keydown", (e) => {
if (e.isComposing || composing || e.key === "Process") return;
if (["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "CapsLock"].includes(e.code)) return;
const cmd = e.metaKey || e.ctrlKey;
if (cmd && e.code === "KeyV") return;
if (cmd && commands[e.code] && !e.altKey) {
send({ type: "command", text: e.code === "KeyZ" && e.shiftKey ? "redo" : commands[e.code] });
} else {
send({ type: "key", code: e.code, text: e.key.length === 1 ? e.key : "", mods: mods(e) });
}
e.preventDefault();
});
keys.addEventListener("compositionstart", () => { composing = true; });
keys.addEventListener("compositionupdate", (e) => send({ type: "compose", text: e.data, caret: [...e.data].length }));
keys.addEventListener("compositionend", (e) => {
composing = false;
send({ type: "compose", text: "", caret: 0 });
if (e.data) send({ type: "text", text: e.data });
keys.value = "";
});
keys.addEventListener("input", () => { if (!composing) keys.value = ""; });
keys.addEventListener("paste", (e) => { send({ type: "paste", text: e.clipboardData.getData("text/plain") }); e.preventDefault(); });
window.addEventListener("focus", () => send({ type: "focus", text: "in" }));
window.addEventListener("blur", () => send({ type: "focus", text: "out" }));
connect();
canvas.focus();
</script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
package main
import "github.com/egoist/mygo/ui"
// keyCodes maps KeyboardEvent.code to MyGo keys.
var keyCodes = map[string]ui.Key{
"Enter": ui.KeyEnter,
"NumpadEnter": ui.KeyEnter,
"Escape": ui.KeyEscape,
"Backspace": ui.KeyBackspace,
"Tab": ui.KeyTab,
"Space": ui.KeySpace,
"Delete": ui.KeyDelete,
"Insert": ui.KeyInsert,
"Home": ui.KeyHome,
"End": ui.KeyEnd,
"PageUp": ui.KeyPageUp,
"PageDown": ui.KeyPageDown,
"ArrowLeft": ui.KeyLeft,
"ArrowRight": ui.KeyRight,
"ArrowUp": ui.KeyUp,
"ArrowDown": ui.KeyDown,
"Minus": ui.KeyMinus,
"Equal": ui.KeyEqual,
"Comma": ui.KeyComma,
"Period": ui.KeyPeriod,
"Slash": ui.KeySlash,
"Semicolon": ui.KeySemicolon,
"Quote": ui.KeyQuote,
"BracketLeft": ui.KeyBracketLeft,
"BracketRight": ui.KeyBracketRight,
"Backslash": ui.KeyBackslash,
"Backquote": ui.KeyBackquote,
"ContextMenu": ui.KeyContextMenu,
"BrowserBack": ui.KeyBack,
"BrowserForward": ui.KeyForward,
"F1": ui.KeyF1,
"F2": ui.KeyF2,
"F3": ui.KeyF3,
"F4": ui.KeyF4,
"F5": ui.KeyF5,
"F6": ui.KeyF6,
"F7": ui.KeyF7,
"F8": ui.KeyF8,
"F9": ui.KeyF9,
"F10": ui.KeyF10,
"F11": ui.KeyF11,
"F12": ui.KeyF12,
"Digit0": ui.Key0,
"Digit1": ui.Key1,
"Digit2": ui.Key2,
"Digit3": ui.Key3,
"Digit4": ui.Key4,
"Digit5": ui.Key5,
"Digit6": ui.Key6,
"Digit7": ui.Key7,
"Digit8": ui.Key8,
"Digit9": ui.Key9,
"KeyA": ui.KeyA,
"KeyB": ui.KeyB,
"KeyC": ui.KeyC,
"KeyD": ui.KeyD,
"KeyE": ui.KeyE,
"KeyF": ui.KeyF,
"KeyG": ui.KeyG,
"KeyH": ui.KeyH,
"KeyI": ui.KeyI,
"KeyJ": ui.KeyJ,
"KeyK": ui.KeyK,
"KeyL": ui.KeyL,
"KeyM": ui.KeyM,
"KeyN": ui.KeyN,
"KeyO": ui.KeyO,
"KeyP": ui.KeyP,
"KeyQ": ui.KeyQ,
"KeyR": ui.KeyR,
"KeyS": ui.KeyS,
"KeyT": ui.KeyT,
"KeyU": ui.KeyU,
"KeyV": ui.KeyV,
"KeyW": ui.KeyW,
"KeyX": ui.KeyX,
"KeyY": ui.KeyY,
"KeyZ": ui.KeyZ,
}
// cursorCSS names a MyGo cursor in CSS.
func cursorCSS(c ui.Cursor) string {
switch c {
case ui.CursorDefault:
return "default"
case ui.CursorPointer:
return "pointer"
case ui.CursorText:
return "text"
case ui.CursorMove:
return "move"
case ui.CursorResizeEW:
return "ew-resize"
case ui.CursorResizeNS:
return "ns-resize"
case ui.CursorResizeNWSE:
return "nwse-resize"
case ui.CursorResizeNESW:
return "nesw-resize"
case ui.CursorNotAllowed:
return "not-allowed"
case ui.CursorCrosshair:
return "crosshair"
case ui.CursorGrab:
return "grab"
case ui.CursorGrabbing:
return "grabbing"
case ui.CursorResizeN:
return "n-resize"
case ui.CursorResizeE:
return "e-resize"
case ui.CursorResizeS:
return "s-resize"
case ui.CursorResizeW:
return "w-resize"
case ui.CursorResizeColumn:
return "col-resize"
case ui.CursorResizeRow:
return "row-resize"
case ui.CursorVerticalText:
return "vertical-text"
case ui.CursorCopy:
return "copy"
case ui.CursorAlias:
return "alias"
case ui.CursorContextMenu:
return "context-menu"
case ui.CursorNone:
return "none"
}
panic("gallery: unmapped cursor")
}