Compare commits
2
Commits
41867429a5
...
d6fda83397
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6fda83397 | ||
|
|
5b4a0d0785 |
@@ -268,6 +268,18 @@ distrobox enter --no-workdir captioneer-dev -- bash -lc '
|
|||||||
|
|
||||||
Run this from the repository root. The build and run helpers resolve that path automatically.
|
Run this from the repository root. The build and run helpers resolve that path automatically.
|
||||||
|
|
||||||
|
### Manual desktop smoke test
|
||||||
|
|
||||||
|
`scripts/smoke/desktop-smoke.sh` boots a nested headless Wayland desktop (sway) inside the `captioneer-dev` Distrobox, routes synthesized `espeak-ng` speech through a temporary PipeWire null sink, runs the real `build/captioneer-desktop` binary against it, and fails on ERROR diagnostics, an unclean shutdown, or an empty caption transcript.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/download-models.sh # once, if models/ is missing
|
||||||
|
./scripts/distrobox/build.sh # produce build/captioneer-desktop
|
||||||
|
./scripts/smoke/desktop-smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit `0` means pass; a report is written under `build/smoke/<timestamp>/report.txt` together with the application logs and caption transcripts. The harness installs `sway` and `espeak-ng` into the Distrobox on demand and restores the previous default sink when it finishes. If VAD recognition of synthetic speech proves flaky, rerun with `CAPTIONEER_SMOKE_MODE=fixed`. This test is manual-only and intentionally not part of CI: it needs downloaded models, a nested compositor, and live audio routing.
|
||||||
|
|
||||||
## Gitea Actions and releases
|
## Gitea Actions and releases
|
||||||
|
|
||||||
The `Tests` workflow runs the normal and GTK-tagged Go tests, `go vet`, and ShellCheck for every pull request and branch push. To make it mandatory for pull requests, open the repository's **Settings → Branches**, edit the protection rule for `master`, enable required status checks, and select **Tests / Go and GTK tests** after its first run. Workflow files can report the check, but the branch-protection rule is what prevents merging when it fails.
|
The `Tests` workflow runs the normal and GTK-tagged Go tests, `go vet`, and ShellCheck for every pull request and branch push. To make it mandatory for pull requests, open the repository's **Settings → Branches**, edit the protection rule for `master`, enable required status checks, and select **Tests / Go and GTK tests** after its first run. Workflow files can report the check, but the branch-protection rule is what prevents merging when it fails.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
name: Desktop smoke harness
|
||||||
|
overview: Add a Distrobox-based nested Wayland desktop smoke test that boots a headless compositor, plays synthesized speech into a temporary PipeWire null sink, runs captioneer-desktop, and fails on errors or missing captions — fast enough for manual runs, realistic enough to catch integration breakages.
|
||||||
|
todos:
|
||||||
|
- id: smoke-lib
|
||||||
|
content: Add scripts/smoke/lib.sh (null sink, espeak fixture, wait helpers, cleanup traps)
|
||||||
|
status: completed
|
||||||
|
- id: smoke-script
|
||||||
|
content: Add scripts/smoke/desktop-smoke.sh orchestrating labwc + captioneer-desktop + assertions
|
||||||
|
status: completed
|
||||||
|
- id: smoke-deps
|
||||||
|
content: On-demand Distrobox package install for labwc/espeak-ng (and ffmpeg if needed)
|
||||||
|
status: completed
|
||||||
|
- id: smoke-docs-ci
|
||||||
|
content: Document in README; ShellCheck the new scripts from scripts/ci/test.sh
|
||||||
|
status: in_progress
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Desktop smoke harness
|
||||||
|
|
||||||
|
## Approach (chosen default)
|
||||||
|
|
||||||
|
**Not a full QEMU VM.** A nested Wayland compositor inside the existing Distrobox is the better tradeoff: boots in seconds, still exercises GTK, Layer Shell, Pulse capture, model worker, and caption logging. Full VMs add minutes of boot/image management without much extra signal for this app.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
host[Host PipeWire] --> nullSink[temp null sink]
|
||||||
|
script[desktop-smoke.sh] --> box[captioneer-dev Distrobox]
|
||||||
|
box --> labwc[labwc headless Wayland]
|
||||||
|
box --> app[captioneer-desktop]
|
||||||
|
nullSink --> play[paplay espeak WAV]
|
||||||
|
nullSink --> app
|
||||||
|
app --> log[stderr diagnostics]
|
||||||
|
app --> transcript[caption-log dir]
|
||||||
|
script --> assert[pass/fail report]
|
||||||
|
log --> assert
|
||||||
|
transcript --> assert
|
||||||
|
```
|
||||||
|
|
||||||
|
## What you will run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/download-models.sh # once, if models/ missing
|
||||||
|
./scripts/distrobox/build.sh # ensure build/captioneer-desktop exists
|
||||||
|
./scripts/smoke/desktop-smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit `0` = pass. Prints a short report under `build/smoke/`.
|
||||||
|
|
||||||
|
## Harness layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| [`scripts/smoke/desktop-smoke.sh`](scripts/smoke/desktop-smoke.sh) | Orchestrator (manual entrypoint) |
|
||||||
|
| [`scripts/smoke/lib.sh`](scripts/smoke/lib.sh) | Shared helpers: null sink, espeak fixture, wait/assert |
|
||||||
|
| Extend [`scripts/distrobox/common.sh`](scripts/distrobox/common.sh) | Add smoke packages: `labwc`, `espeak-ng`, `ffmpeg` (or keep install-on-demand in smoke script to avoid bloating every build) |
|
||||||
|
|
||||||
|
**Package strategy:** install smoke deps on demand inside `desktop-smoke.sh` via `sudo dnf install -y` in the Distrobox (same pattern as `ensure_dependencies`), so normal `build.sh` / `run.sh` stay lean.
|
||||||
|
|
||||||
|
## Test sequence
|
||||||
|
|
||||||
|
1. **Preflight**
|
||||||
|
- Require Distrobox, `build/captioneer-desktop`, `models/parakeet-tdt-v2/`, `models/silero_vad_v5.onnx`
|
||||||
|
- Create work dir `build/smoke/<timestamp>/`
|
||||||
|
|
||||||
|
2. **Audio sandbox (host-visible via Distrobox Pulse integration)**
|
||||||
|
- `pactl load-module module-null-sink sink_name=captioneer-smoke`
|
||||||
|
- Save/restore previous default sink
|
||||||
|
- `pactl set-default-sink captioneer-smoke`
|
||||||
|
- Synthesize a short clear English line with `espeak-ng` → WAV in the work dir
|
||||||
|
- Loop-play with `paplay` for ~15–20s so VAD/fixed mode has time to finalize
|
||||||
|
|
||||||
|
3. **Nested desktop**
|
||||||
|
- Start `labwc` (wlroots, Layer Shell capable) with `WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1`
|
||||||
|
- Export `WAYLAND_DISPLAY` to that socket; `GSK_RENDERER=cairo`
|
||||||
|
|
||||||
|
4. **Launch Captioneer**
|
||||||
|
```bash
|
||||||
|
./build/captioneer-desktop \
|
||||||
|
--no-config \
|
||||||
|
--start-captions=true \
|
||||||
|
--output=both \
|
||||||
|
--stderr-diagnostics=true \
|
||||||
|
--caption-log=true \
|
||||||
|
--caption-log-dir="$workdir/transcripts" \
|
||||||
|
--models-dir=models \
|
||||||
|
--mode=vad \
|
||||||
|
--model-auto-restart=false
|
||||||
|
```
|
||||||
|
- Redirect stderr to `$workdir/captioneer.stderr.log`
|
||||||
|
- Present main window first (already true in current [`src/gui/app.go`](src/gui/app.go)), so compositor + GUI come up before capture
|
||||||
|
|
||||||
|
5. **Settle + stop**
|
||||||
|
- Wait until stderr contains `Model worker ready` (timeout ~60s)
|
||||||
|
- Keep playing speech ~15s more
|
||||||
|
- Send clean shutdown (`gdbus` `app.exit` or SIGINT)
|
||||||
|
- Wait for exit; unload null sink; kill labwc
|
||||||
|
|
||||||
|
6. **Assertions (fail hard)**
|
||||||
|
- Process exited 0 (or SIGINT-clean)
|
||||||
|
- stderr has `Model worker ready` and `Capturing from:`
|
||||||
|
- No `ERROR` lines in stderr (allow listed WARNINGs only if needed, e.g. always-on-top on Wayland)
|
||||||
|
- At least one non-empty caption log file under the smoke transcript dir **or**, if recognition is too flaky on synthetic speech, fall back to requiring `INFO [lifecycle]` start/stop without ERROR and treat caption text as a soft check printed in the report
|
||||||
|
|
||||||
|
**Caption reliability default:** require caption log content. If espeak+VAD proves flaky in practice, switch the harness to `--mode=fixed --chunk-duration=1s` (still in the same script) before softening the assertion — fixed mode is more predictable with continuous playback.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
Write `$workdir/report.txt` with: compositor, sink name, key log greps, transcript excerpt, pass/fail. Always print the report path on exit.
|
||||||
|
|
||||||
|
## Docs / CI wiring
|
||||||
|
|
||||||
|
- Short “Manual desktop smoke” subsection in [`README.md`](README.md) Development section
|
||||||
|
- Add `scripts/smoke/desktop-smoke.sh` + `lib.sh` to ShellCheck in [`scripts/ci/test.sh`](scripts/ci/test.sh)
|
||||||
|
- **Do not** add this to Gitea CI yet (needs models + nested compositor + audio; too heavy/flaky for every PR). Manual-only until it proves stable.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Full QEMU/libvirt guest image
|
||||||
|
- Automating CTRL+drag overlay positioning
|
||||||
|
- Pixel screenshot OCR of the overlay bubble
|
||||||
+3
-1
@@ -16,4 +16,6 @@ shellcheck \
|
|||||||
scripts/distrobox/run.sh \
|
scripts/distrobox/run.sh \
|
||||||
scripts/ci/test.sh \
|
scripts/ci/test.sh \
|
||||||
scripts/ci/package-release.sh \
|
scripts/ci/package-release.sh \
|
||||||
scripts/ci/publish-release.sh
|
scripts/ci/publish-release.sh \
|
||||||
|
scripts/smoke/lib.sh \
|
||||||
|
scripts/smoke/desktop-smoke.sh
|
||||||
|
|||||||
@@ -15,13 +15,18 @@ cd -- "$repo_root"
|
|||||||
# Avoid host/container DMABUF incompatibilities on mismatched GPU stacks. An
|
# Avoid host/container DMABUF incompatibilities on mismatched GPU stacks. An
|
||||||
# explicit GTK renderer choice still takes precedence.
|
# explicit GTK renderer choice still takes precedence.
|
||||||
export GSK_RENDERER="${GSK_RENDERER:-cairo}"
|
export GSK_RENDERER="${GSK_RENDERER:-cairo}"
|
||||||
# gtk4-layer-shell must load before libwayland-client or its Wayland shim is
|
# gtk4-layer-shell must precede libwayland-client in the link line. Do NOT
|
||||||
# skipped and the overlay sits as an unpositioned normal window.
|
# LD_PRELOAD it here: the shim breaks the main GtkApplication window on
|
||||||
layer_shell_lib="$(pkg-config --variable=libdir gtk4-layer-shell-0)/libgtk4-layer-shell.so.0"
|
# Wayland when forced onto the whole process.
|
||||||
if [[ -f "$layer_shell_lib" ]]; then
|
|
||||||
export LD_PRELOAD="${layer_shell_lib}${LD_PRELOAD:+:$LD_PRELOAD}"
|
|
||||||
fi
|
|
||||||
export CGO_LDFLAGS="-Wl,--no-as-needed -lgtk4-layer-shell ${CGO_LDFLAGS:-}"
|
export CGO_LDFLAGS="-Wl,--no-as-needed -lgtk4-layer-shell ${CGO_LDFLAGS:-}"
|
||||||
|
|
||||||
|
desktop_bin=build/captioneer-desktop
|
||||||
|
if [[ -x "$desktop_bin" ]]; then
|
||||||
|
printf "captioneer: launching %s\\n" "$desktop_bin" >&2
|
||||||
|
exec "./$desktop_bin" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "captioneer: no build/captioneer-desktop yet; compiling with go run (first launch is slow)\\n" >&2
|
||||||
exec go run -tags gtk ./src/cmd/captioneer-desktop "$@"'
|
exec go run -tags gtk ./src/cmd/captioneer-desktop "$@"'
|
||||||
|
|
||||||
exec distrobox enter --no-workdir --name "$container_name" -- \
|
exec distrobox enter --no-workdir --name "$container_name" -- \
|
||||||
|
|||||||
Executable
+190
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Manual desktop smoke test.
|
||||||
|
#
|
||||||
|
# Boots a headless Wayland compositor (sway) inside the captioneer-dev
|
||||||
|
# Distrobox, routes synthesized speech through a temporary PipeWire null
|
||||||
|
# sink, runs the real captioneer-desktop binary against it, and fails on
|
||||||
|
# diagnostics errors or missing captions.
|
||||||
|
#
|
||||||
|
# Usage (from the repository root, on the host):
|
||||||
|
# ./scripts/download-models.sh # once
|
||||||
|
# ./scripts/distrobox/build.sh # produce build/captioneer-desktop
|
||||||
|
# ./scripts/smoke/desktop-smoke.sh
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# CAPTIONEER_SMOKE_MODE vad (default) or fixed
|
||||||
|
# CAPTIONEER_SMOKE_SPEECH_S seconds of speech to play (default 20)
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
repo_root="$(cd -- "$script_dir/../.." && pwd -P)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Host phase: prepare the Distrobox and re-execute this script inside it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ -z "${CAPTIONEER_SMOKE_INNER:-}" && -z "${CONTAINER_ID:-}" ]]; then
|
||||||
|
# shellcheck source=scripts/distrobox/common.sh
|
||||||
|
source "$repo_root/scripts/distrobox/common.sh"
|
||||||
|
prepare_distrobox
|
||||||
|
|
||||||
|
[[ -x "$repo_root/build/captioneer-desktop" ]] \
|
||||||
|
|| die "build/captioneer-desktop is missing; run ./scripts/distrobox/build.sh first"
|
||||||
|
[[ -d "$repo_root/models/parakeet-tdt-v2" ]] \
|
||||||
|
|| die "models/parakeet-tdt-v2 is missing; run ./scripts/download-models.sh first"
|
||||||
|
[[ -f "$repo_root/models/silero_vad_v5.onnx" ]] \
|
||||||
|
|| die "models/silero_vad_v5.onnx is missing; run ./scripts/download-models.sh first"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016 # Positional parameters expand inside the container.
|
||||||
|
# shellcheck disable=SC2154 # container_name is defined by common.sh above.
|
||||||
|
exec distrobox enter --no-workdir --name "$container_name" -- \
|
||||||
|
bash -lc 'CAPTIONEER_SMOKE_INNER=1 exec bash "$1"' captioneer-smoke \
|
||||||
|
"$repo_root/scripts/smoke/desktop-smoke.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inner phase: runs inside the Distrobox.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# shellcheck source=scripts/smoke/lib.sh
|
||||||
|
source "$script_dir/lib.sh"
|
||||||
|
|
||||||
|
smoke_mode="${CAPTIONEER_SMOKE_MODE:-vad}"
|
||||||
|
speech_seconds="${CAPTIONEER_SMOKE_SPEECH_S:-20}"
|
||||||
|
|
||||||
|
# On-demand smoke dependencies; kept out of common.sh so normal builds stay lean.
|
||||||
|
smoke_packages=(sway espeak-ng)
|
||||||
|
missing_packages=()
|
||||||
|
for package in "${smoke_packages[@]}"; do
|
||||||
|
rpm -q "$package" >/dev/null 2>&1 || missing_packages+=("$package")
|
||||||
|
done
|
||||||
|
if (( ${#missing_packages[@]} > 0 )); then
|
||||||
|
smoke_log "installing smoke dependencies: ${missing_packages[*]}"
|
||||||
|
sudo dnf install -y "${missing_packages[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd -- "$repo_root"
|
||||||
|
workdir="$repo_root/build/smoke/$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$workdir/transcripts"
|
||||||
|
smoke_log "work directory: $workdir"
|
||||||
|
|
||||||
|
trap smoke_cleanup EXIT
|
||||||
|
|
||||||
|
smoke_setup_audio "$workdir"
|
||||||
|
wayland_socket="$(smoke_start_compositor "$workdir")"
|
||||||
|
smoke_log "nested compositor ready on $wayland_socket"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Launch the desktop application.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
env \
|
||||||
|
WAYLAND_DISPLAY="$wayland_socket" \
|
||||||
|
GDK_BACKEND=wayland \
|
||||||
|
GSK_RENDERER=cairo \
|
||||||
|
./build/captioneer-desktop \
|
||||||
|
--no-config \
|
||||||
|
--start-captions=true \
|
||||||
|
--output=both \
|
||||||
|
--stderr-diagnostics=true \
|
||||||
|
--caption-log=true \
|
||||||
|
--caption-log-dir="$workdir/transcripts" \
|
||||||
|
--models-dir=models \
|
||||||
|
--mode="$smoke_mode" \
|
||||||
|
--model-auto-restart=false \
|
||||||
|
> "$workdir/captioneer.stdout.log" \
|
||||||
|
2> "$workdir/captioneer.stderr.log" &
|
||||||
|
SMOKE_APP_PID=$!
|
||||||
|
smoke_log "captioneer-desktop started (pid $SMOKE_APP_PID, mode $smoke_mode)"
|
||||||
|
|
||||||
|
stderr_log="$workdir/captioneer.stderr.log"
|
||||||
|
|
||||||
|
if ! smoke_wait_for_line "$stderr_log" "Model worker ready" 60; then
|
||||||
|
smoke_fail "model worker did not become ready within 60s"
|
||||||
|
fi
|
||||||
|
if ! smoke_wait_for_line "$stderr_log" "Capturing from:" 30; then
|
||||||
|
smoke_fail "audio capture did not start within 30s"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#SMOKE_FAILURES[@]} == 0 )); then
|
||||||
|
smoke_log "playing synthesized speech for ${speech_seconds}s"
|
||||||
|
smoke_play_speech "$workdir" "$speech_seconds"
|
||||||
|
wait "$SMOKE_PLAYER_PID" 2>/dev/null || true
|
||||||
|
SMOKE_PLAYER_PID=""
|
||||||
|
# Allow VAD finalization (500ms pause) and transcript flush to settle.
|
||||||
|
sleep 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
smoke_stop_app
|
||||||
|
app_exit=0
|
||||||
|
if smoke_wait_for_exit "$SMOKE_APP_PID" 20; then
|
||||||
|
wait "$SMOKE_APP_PID" 2>/dev/null || app_exit=$?
|
||||||
|
else
|
||||||
|
smoke_fail "application did not exit within 20s of SIGINT"
|
||||||
|
kill -KILL "$SMOKE_APP_PID" 2>/dev/null || true
|
||||||
|
app_exit=137
|
||||||
|
fi
|
||||||
|
SMOKE_APP_PID=""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Assertions.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if (( app_exit == 0 )); then
|
||||||
|
smoke_pass "clean shutdown (exit 0)"
|
||||||
|
else
|
||||||
|
smoke_fail "application exited with status $app_exit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "Model worker ready" "$stderr_log"; then
|
||||||
|
smoke_pass "model worker started"
|
||||||
|
fi
|
||||||
|
if grep -q "Capturing from:" "$stderr_log"; then
|
||||||
|
smoke_pass "audio capture attached to a monitor source"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -E " ERROR " "$stderr_log" > "$workdir/errors.txt"; then
|
||||||
|
smoke_fail "diagnostics contain ERROR lines (see errors.txt)"
|
||||||
|
else
|
||||||
|
smoke_pass "no ERROR diagnostics"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t caption_files < <(find "$workdir/transcripts" -type f -size +0c 2>/dev/null)
|
||||||
|
if (( ${#caption_files[@]} > 0 )); then
|
||||||
|
smoke_pass "caption log contains recognized speech"
|
||||||
|
else
|
||||||
|
smoke_fail "no captions were recognized; inspect $workdir and consider CAPTIONEER_SMOKE_MODE=fixed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Report.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
report="$workdir/report.txt"
|
||||||
|
{
|
||||||
|
printf 'Captioneer desktop smoke report\n'
|
||||||
|
printf 'Date: %s\n' "$(date --rfc-3339=seconds)"
|
||||||
|
printf 'Mode: %s\n' "$smoke_mode"
|
||||||
|
printf 'Compositor: sway headless (%s)\n' "$wayland_socket"
|
||||||
|
printf 'Sink: %s\n' "$SMOKE_SINK_NAME"
|
||||||
|
printf 'App exit: %s\n' "$app_exit"
|
||||||
|
printf '\n--- Key diagnostics ---\n'
|
||||||
|
grep -E " (ERROR|WARNING) |Model worker ready|Capturing from:|Shutting down" \
|
||||||
|
"$stderr_log" 2>/dev/null || printf '(none)\n'
|
||||||
|
printf '\n--- Caption transcript excerpt ---\n'
|
||||||
|
if (( ${#caption_files[@]} > 0 )); then
|
||||||
|
head -n 10 "${caption_files[@]}" 2>/dev/null
|
||||||
|
else
|
||||||
|
printf '(empty)\n'
|
||||||
|
fi
|
||||||
|
printf '\n--- Result ---\n'
|
||||||
|
if (( ${#SMOKE_FAILURES[@]} == 0 )); then
|
||||||
|
printf 'PASS\n'
|
||||||
|
else
|
||||||
|
printf 'FAIL:\n'
|
||||||
|
printf ' - %s\n' "${SMOKE_FAILURES[@]}"
|
||||||
|
fi
|
||||||
|
} > "$report"
|
||||||
|
|
||||||
|
cat "$report"
|
||||||
|
smoke_log "full report: $report"
|
||||||
|
|
||||||
|
if (( ${#SMOKE_FAILURES[@]} > 0 )); then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+152
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for the manual desktop smoke test. Sourced by
|
||||||
|
# scripts/smoke/desktop-smoke.sh inside the captioneer-dev Distrobox.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SMOKE_SINK_NAME="captioneer-smoke"
|
||||||
|
SMOKE_SINK_MODULE=""
|
||||||
|
SMOKE_PREVIOUS_SINK=""
|
||||||
|
SMOKE_COMPOSITOR_PID=""
|
||||||
|
SMOKE_APP_PID=""
|
||||||
|
SMOKE_PLAYER_PID=""
|
||||||
|
SMOKE_FAILURES=()
|
||||||
|
|
||||||
|
smoke_log() {
|
||||||
|
printf 'smoke: %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_die() {
|
||||||
|
printf 'smoke: FATAL: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_fail() {
|
||||||
|
SMOKE_FAILURES+=("$1")
|
||||||
|
smoke_log "FAIL: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_pass() {
|
||||||
|
smoke_log "ok: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# smoke_wait_for_line FILE PATTERN TIMEOUT_SECONDS
|
||||||
|
smoke_wait_for_line() {
|
||||||
|
local file=$1 pattern=$2 timeout=$3 waited=0
|
||||||
|
until grep -q -- "$pattern" "$file" 2>/dev/null; do
|
||||||
|
if (( waited >= timeout )); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# smoke_wait_for_exit PID TIMEOUT_SECONDS; returns 1 on timeout
|
||||||
|
smoke_wait_for_exit() {
|
||||||
|
local pid=$1 timeout=$2 waited=0
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
if (( waited >= timeout )); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_setup_audio() {
|
||||||
|
local workdir=$1
|
||||||
|
command -v pactl >/dev/null 2>&1 || smoke_die "pactl is required"
|
||||||
|
command -v paplay >/dev/null 2>&1 || smoke_die "paplay is required"
|
||||||
|
SMOKE_PREVIOUS_SINK="$(pactl get-default-sink 2>/dev/null || true)"
|
||||||
|
SMOKE_SINK_MODULE="$(pactl load-module module-null-sink \
|
||||||
|
"sink_name=$SMOKE_SINK_NAME" \
|
||||||
|
"sink_properties=device.description=Captioneer-Smoke")"
|
||||||
|
pactl set-default-sink "$SMOKE_SINK_NAME"
|
||||||
|
smoke_log "null sink $SMOKE_SINK_NAME loaded (module $SMOKE_SINK_MODULE, previous default: ${SMOKE_PREVIOUS_SINK:-none})"
|
||||||
|
|
||||||
|
command -v espeak-ng >/dev/null 2>&1 || smoke_die "espeak-ng is required"
|
||||||
|
espeak-ng -v en-us -s 140 -w "$workdir/speech.wav" \
|
||||||
|
"The quick brown fox jumps over the lazy dog. Live captions should appear for this sentence."
|
||||||
|
[[ -s "$workdir/speech.wav" ]] || smoke_die "espeak-ng produced no audio fixture"
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_teardown_audio() {
|
||||||
|
if [[ -n "$SMOKE_PLAYER_PID" ]] && kill -0 "$SMOKE_PLAYER_PID" 2>/dev/null; then
|
||||||
|
kill "$SMOKE_PLAYER_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -n "$SMOKE_PREVIOUS_SINK" ]]; then
|
||||||
|
pactl set-default-sink "$SMOKE_PREVIOUS_SINK" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -n "$SMOKE_SINK_MODULE" ]]; then
|
||||||
|
pactl unload-module "$SMOKE_SINK_MODULE" 2>/dev/null || true
|
||||||
|
SMOKE_SINK_MODULE=""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# smoke_play_speech WORKDIR SECONDS: loop the fixture into the null sink.
|
||||||
|
smoke_play_speech() {
|
||||||
|
local workdir=$1 seconds=$2
|
||||||
|
(
|
||||||
|
end=$((SECONDS + seconds))
|
||||||
|
while (( SECONDS < end )); do
|
||||||
|
paplay --device="$SMOKE_SINK_NAME" "$workdir/speech.wav" 2>/dev/null || sleep 1
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
SMOKE_PLAYER_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
# smoke_start_compositor WORKDIR: starts headless sway; prints new socket name.
|
||||||
|
# sway is wlroots-based (Layer Shell capable) and, unlike labwc, can disable
|
||||||
|
# Xwayland, whose sockets are not creatable inside a rootless Distrobox.
|
||||||
|
smoke_start_compositor() {
|
||||||
|
local workdir=$1 candidate socket="" waited=0
|
||||||
|
command -v sway >/dev/null 2>&1 || smoke_die "sway is required"
|
||||||
|
local before="$workdir/sockets-before.txt"
|
||||||
|
find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'wayland-[0-9]*' ! -name '*.lock' \
|
||||||
|
-printf '%f\n' 2>/dev/null | sort > "$before"
|
||||||
|
|
||||||
|
printf 'xwayland disable\n' > "$workdir/sway-config"
|
||||||
|
WLR_BACKENDS=headless \
|
||||||
|
WLR_LIBINPUT_NO_DEVICES=1 \
|
||||||
|
WLR_RENDERER=pixman \
|
||||||
|
sway -c "$workdir/sway-config" > "$workdir/compositor.log" 2>&1 &
|
||||||
|
SMOKE_COMPOSITOR_PID=$!
|
||||||
|
|
||||||
|
while (( waited < 15 )); do
|
||||||
|
while IFS= read -r candidate; do
|
||||||
|
socket="$candidate"
|
||||||
|
done < <(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'wayland-[0-9]*' ! -name '*.lock' \
|
||||||
|
-printf '%f\n' 2>/dev/null | sort | comm -13 "$before" -)
|
||||||
|
if [[ -n "$socket" ]]; then
|
||||||
|
printf '%s\n' "$socket"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
kill -0 "$SMOKE_COMPOSITOR_PID" 2>/dev/null || smoke_die "sway exited early; see $workdir/compositor.log"
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
smoke_die "sway did not create a Wayland socket; see $workdir/compositor.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_teardown_compositor() {
|
||||||
|
if [[ -n "$SMOKE_COMPOSITOR_PID" ]] && kill -0 "$SMOKE_COMPOSITOR_PID" 2>/dev/null; then
|
||||||
|
kill "$SMOKE_COMPOSITOR_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_stop_app() {
|
||||||
|
if [[ -n "$SMOKE_APP_PID" ]] && kill -0 "$SMOKE_APP_PID" 2>/dev/null; then
|
||||||
|
kill -INT "$SMOKE_APP_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
smoke_cleanup() {
|
||||||
|
smoke_stop_app
|
||||||
|
if [[ -n "$SMOKE_APP_PID" ]]; then
|
||||||
|
smoke_wait_for_exit "$SMOKE_APP_PID" 10 || kill -KILL "$SMOKE_APP_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
smoke_teardown_audio
|
||||||
|
smoke_teardown_compositor
|
||||||
|
}
|
||||||
@@ -76,6 +76,10 @@ type OverlaySettings struct {
|
|||||||
Monitor string
|
Monitor string
|
||||||
FinalTimeout time.Duration
|
FinalTimeout time.Duration
|
||||||
ClickThrough bool
|
ClickThrough bool
|
||||||
|
// PositionX/PositionY are monitor-local logical pixels. -1 means automatic
|
||||||
|
// placement (horizontally centered, BottomMargin above the bottom edge).
|
||||||
|
PositionX int
|
||||||
|
PositionY int
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultOverlaySettings() OverlaySettings {
|
func DefaultOverlaySettings() OverlaySettings {
|
||||||
@@ -89,6 +93,8 @@ func DefaultOverlaySettings() OverlaySettings {
|
|||||||
Monitor: "auto",
|
Monitor: "auto",
|
||||||
FinalTimeout: MinimumOverlayFinalLifetime,
|
FinalTimeout: MinimumOverlayFinalLifetime,
|
||||||
ClickThrough: true,
|
ClickThrough: true,
|
||||||
|
PositionX: -1,
|
||||||
|
PositionY: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +208,12 @@ func (s OverlaySettings) Validate() error {
|
|||||||
if s.Monitor == "" {
|
if s.Monitor == "" {
|
||||||
return errors.New("--overlay-monitor cannot be empty")
|
return errors.New("--overlay-monitor cannot be empty")
|
||||||
}
|
}
|
||||||
|
if s.PositionX < -1 {
|
||||||
|
return errors.New("overlay position x cannot be less than -1")
|
||||||
|
}
|
||||||
|
if s.PositionY < -1 {
|
||||||
|
return errors.New("overlay position y cannot be less than -1")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type diskConfig struct {
|
|||||||
Monitor string `json:"monitor"`
|
Monitor string `json:"monitor"`
|
||||||
FinalTimeout string `json:"final_timeout"`
|
FinalTimeout string `json:"final_timeout"`
|
||||||
ClickThrough bool `json:"click_through"`
|
ClickThrough bool `json:"click_through"`
|
||||||
|
PositionX *int `json:"position_x,omitempty"`
|
||||||
|
PositionY *int `json:"position_y,omitempty"`
|
||||||
} `json:"overlay"`
|
} `json:"overlay"`
|
||||||
TranscriptLog struct {
|
TranscriptLog struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -163,6 +165,10 @@ func diskFromConfig(config AppConfig) diskConfig {
|
|||||||
disk.Overlay.Monitor = config.Overlay.Monitor
|
disk.Overlay.Monitor = config.Overlay.Monitor
|
||||||
disk.Overlay.FinalTimeout = config.Overlay.FinalTimeout.String()
|
disk.Overlay.FinalTimeout = config.Overlay.FinalTimeout.String()
|
||||||
disk.Overlay.ClickThrough = config.Overlay.ClickThrough
|
disk.Overlay.ClickThrough = config.Overlay.ClickThrough
|
||||||
|
positionX := config.Overlay.PositionX
|
||||||
|
positionY := config.Overlay.PositionY
|
||||||
|
disk.Overlay.PositionX = &positionX
|
||||||
|
disk.Overlay.PositionY = &positionY
|
||||||
disk.TranscriptLog.Enabled = config.TranscriptLog.Enabled
|
disk.TranscriptLog.Enabled = config.TranscriptLog.Enabled
|
||||||
disk.TranscriptLog.Directory = config.TranscriptLog.Directory
|
disk.TranscriptLog.Directory = config.TranscriptLog.Directory
|
||||||
return disk
|
return disk
|
||||||
@@ -185,6 +191,13 @@ func (disk diskConfig) toConfig() (AppConfig, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AppConfig{}, err
|
return AppConfig{}, err
|
||||||
}
|
}
|
||||||
|
positionX, positionY := -1, -1
|
||||||
|
if disk.Overlay.PositionX != nil {
|
||||||
|
positionX = *disk.Overlay.PositionX
|
||||||
|
}
|
||||||
|
if disk.Overlay.PositionY != nil {
|
||||||
|
positionY = *disk.Overlay.PositionY
|
||||||
|
}
|
||||||
return AppConfig{
|
return AppConfig{
|
||||||
Version: disk.Version,
|
Version: disk.Version,
|
||||||
GUI: GUISettings{
|
GUI: GUISettings{
|
||||||
@@ -218,6 +231,8 @@ func (disk diskConfig) toConfig() (AppConfig, error) {
|
|||||||
Monitor: disk.Overlay.Monitor,
|
Monitor: disk.Overlay.Monitor,
|
||||||
FinalTimeout: finalTimeout,
|
FinalTimeout: finalTimeout,
|
||||||
ClickThrough: disk.Overlay.ClickThrough,
|
ClickThrough: disk.Overlay.ClickThrough,
|
||||||
|
PositionX: positionX,
|
||||||
|
PositionY: positionY,
|
||||||
},
|
},
|
||||||
TranscriptLog: TranscriptLogSettings{
|
TranscriptLog: TranscriptLogSettings{
|
||||||
Enabled: disk.TranscriptLog.Enabled,
|
Enabled: disk.TranscriptLog.Enabled,
|
||||||
|
|||||||
+16
-1
@@ -143,13 +143,16 @@ func (a *Application) activate() {
|
|||||||
a.buildWindow()
|
a.buildWindow()
|
||||||
a.installActions()
|
a.installActions()
|
||||||
a.subscribeModels()
|
a.subscribeModels()
|
||||||
|
overlay.SetPositionSaver(a.saveOverlayPosition)
|
||||||
if a.launch.Warning != nil {
|
if a.launch.Warning != nil {
|
||||||
a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning))
|
a.log.Add(diagnostics.Warning, "configuration", fmt.Sprintf("%v; using built-in defaults", a.launch.Warning))
|
||||||
}
|
}
|
||||||
|
// Show the main window before creating the overlay so a Layer Shell /
|
||||||
|
// display failure cannot leave the user with a silent no-GUI launch.
|
||||||
|
a.window.Present()
|
||||||
if err := a.configureOutputs(a.config, true); err != nil {
|
if err := a.configureOutputs(a.config, true); err != nil {
|
||||||
a.log.Add(diagnostics.Error, "outputs", err.Error())
|
a.log.Add(diagnostics.Error, "outputs", err.Error())
|
||||||
}
|
}
|
||||||
a.window.Present()
|
|
||||||
a.applyAlwaysOnTop(a.config.GUI.MainWindowAlwaysOnTop)
|
a.applyAlwaysOnTop(a.config.GUI.MainWindowAlwaysOnTop)
|
||||||
if a.config.GUI.StartCaptions {
|
if a.config.GUI.StartCaptions {
|
||||||
a.startCaptions()
|
a.startCaptions()
|
||||||
@@ -395,6 +398,18 @@ func (a *Application) restartCaptions() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Application) saveOverlayPosition(x, y int) {
|
||||||
|
a.config.Overlay.PositionX = x
|
||||||
|
a.config.Overlay.PositionY = y
|
||||||
|
a.applied.Overlay.PositionX = x
|
||||||
|
a.applied.Overlay.PositionY = y
|
||||||
|
if err := config.Save(a.launch.Path, a.config); err != nil {
|
||||||
|
a.log.Add(diagnostics.Error, "configuration", fmt.Sprintf("Save overlay position: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.log.Add(diagnostics.Info, "overlay", fmt.Sprintf("Saved caption position (%d, %d)", x, y))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Application) configureOutputs(next config.AppConfig, initial bool) error {
|
func (a *Application) configureOutputs(next config.AppConfig, initial bool) error {
|
||||||
var outputErr error
|
var outputErr error
|
||||||
a.history.SetEnabled(next.Outputs.History)
|
a.history.SetEnabled(next.Outputs.History)
|
||||||
|
|||||||
+3
-1
@@ -170,7 +170,7 @@ func (a *Application) overlaySettingsPage() *gtk.ScrolledWindow {
|
|||||||
box.Append(settingRow("Maximum width", "Maximum overlay width in pixels.", a.settings.overlayMaxWidth))
|
box.Append(settingRow("Maximum width", "Maximum overlay width in pixels.", a.settings.overlayMaxWidth))
|
||||||
box.Append(settingRow("Monitor", "Use auto or the configured monitor connector/name.", a.settings.overlayMonitor))
|
box.Append(settingRow("Monitor", "Use auto or the configured monitor connector/name.", a.settings.overlayMonitor))
|
||||||
box.Append(settingRow("Final caption lifetime", "Zero keeps final text visible; otherwise use 3s or longer.", a.settings.overlayFinalTimeout))
|
box.Append(settingRow("Final caption lifetime", "Zero keeps final text visible; otherwise use 3s or longer.", a.settings.overlayFinalTimeout))
|
||||||
box.Append(settingRow("Click through overlay", "Allow pointer input to pass through the caption surface.", a.settings.overlayClickThrough))
|
box.Append(settingRow("Click through overlay", "Pass pointer input through empty areas. Hold Ctrl and drag the caption bubble to reposition it; the spot is remembered.", a.settings.overlayClickThrough))
|
||||||
return settingsScroller(box)
|
return settingsScroller(box)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +272,8 @@ func (a *Application) readSettings() (config.AppConfig, error) {
|
|||||||
Monitor: strings.TrimSpace(a.settings.overlayMonitor.Text()),
|
Monitor: strings.TrimSpace(a.settings.overlayMonitor.Text()),
|
||||||
FinalTimeout: finalTimeout,
|
FinalTimeout: finalTimeout,
|
||||||
ClickThrough: a.settings.overlayClickThrough.Active(),
|
ClickThrough: a.settings.overlayClickThrough.Active(),
|
||||||
|
PositionX: a.config.Overlay.PositionX,
|
||||||
|
PositionY: a.config.Overlay.PositionY,
|
||||||
},
|
},
|
||||||
TranscriptLog: config.TranscriptLogSettings{
|
TranscriptLog: config.TranscriptLogSettings{
|
||||||
Enabled: a.settings.transcriptEnabled.Active(),
|
Enabled: a.settings.transcriptEnabled.Active(),
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ typedef struct {
|
|||||||
gboolean final_visible;
|
gboolean final_visible;
|
||||||
gboolean preview_visible;
|
gboolean preview_visible;
|
||||||
gboolean x11;
|
gboolean x11;
|
||||||
|
gboolean layer_shell;
|
||||||
|
gboolean click_through;
|
||||||
|
gboolean dragging;
|
||||||
|
int bottom_margin;
|
||||||
|
int position_x;
|
||||||
|
int position_y;
|
||||||
|
double drag_start_px;
|
||||||
|
double drag_start_py;
|
||||||
|
int drag_origin_x;
|
||||||
|
int drag_origin_y;
|
||||||
} CaptioneerOverlay;
|
} CaptioneerOverlay;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -41,6 +51,9 @@ typedef struct {
|
|||||||
char *preview_text;
|
char *preview_text;
|
||||||
} CaptioneerUpdate;
|
} CaptioneerUpdate;
|
||||||
|
|
||||||
|
// Implemented on the Go side via //export so drag placement can be persisted.
|
||||||
|
extern void captioneerOverlayPositionChanged(int x, int y);
|
||||||
|
|
||||||
static void captioneer_append_warning(GString *warnings, const char *message) {
|
static void captioneer_append_warning(GString *warnings, const char *message) {
|
||||||
if (warnings->len > 0) {
|
if (warnings->len > 0) {
|
||||||
g_string_append(warnings, " ");
|
g_string_append(warnings, " ");
|
||||||
@@ -126,15 +139,198 @@ static GdkMonitor *captioneer_select_monitor(
|
|||||||
return monitor;
|
return monitor;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void captioneer_set_input_passthrough(CaptioneerOverlay *overlay, GString *warnings) {
|
static GdkMonitor *captioneer_overlay_monitor(CaptioneerOverlay *overlay) {
|
||||||
if (!gdk_display_supports_input_shapes(gtk_widget_get_display(GTK_WIDGET(overlay->window)))) {
|
if (overlay->monitor != NULL) {
|
||||||
captioneer_append_warning(warnings, "This display backend does not support pointer click-through.");
|
return overlay->monitor;
|
||||||
|
}
|
||||||
|
GdkDisplay *display = gtk_widget_get_display(GTK_WIDGET(overlay->window));
|
||||||
|
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
||||||
|
if (surface != NULL) {
|
||||||
|
GdkMonitor *monitor = gdk_display_get_monitor_at_surface(display, surface);
|
||||||
|
if (monitor != NULL) {
|
||||||
|
return monitor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return captioneer_monitor_by_index(display, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void captioneer_update_input_region(CaptioneerOverlay *overlay) {
|
||||||
|
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
||||||
|
if (surface == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!overlay->click_through || overlay->dragging) {
|
||||||
|
gdk_surface_set_input_region(surface, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Limit hit-testing to the caption bubble so the transparent full-width
|
||||||
|
// chrome stays click-through. CTRL+drag still works because clicks that
|
||||||
|
// land on the bubble include modifier state.
|
||||||
|
int bubble_width = gtk_widget_get_width(overlay->bubble);
|
||||||
|
int bubble_height = gtk_widget_get_height(overlay->bubble);
|
||||||
|
if (bubble_width <= 0 || bubble_height <= 0) {
|
||||||
|
cairo_region_t *empty = cairo_region_create();
|
||||||
|
gdk_surface_set_input_region(surface, empty);
|
||||||
|
cairo_region_destroy(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double native_x = 0, native_y = 0;
|
||||||
|
if (!gtk_widget_translate_coordinates(overlay->bubble, GTK_WIDGET(overlay->window), 0, 0, &native_x, &native_y)) {
|
||||||
|
native_x = 0;
|
||||||
|
native_y = 0;
|
||||||
|
}
|
||||||
|
cairo_rectangle_int_t rect = {
|
||||||
|
.x = (int)native_x,
|
||||||
|
.y = (int)native_y,
|
||||||
|
.width = bubble_width,
|
||||||
|
.height = bubble_height,
|
||||||
|
};
|
||||||
|
cairo_region_t *region = cairo_region_create_rectangle(&rect);
|
||||||
|
gdk_surface_set_input_region(surface, region);
|
||||||
|
cairo_region_destroy(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void captioneer_apply_placement(CaptioneerOverlay *overlay) {
|
||||||
|
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
||||||
|
if (surface == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GdkMonitor *monitor = captioneer_overlay_monitor(overlay);
|
||||||
|
if (monitor == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GdkRectangle geometry;
|
||||||
|
gdk_monitor_get_geometry(monitor, &geometry);
|
||||||
|
int width = gdk_surface_get_width(surface);
|
||||||
|
int height = gdk_surface_get_height(surface);
|
||||||
|
if (width <= 1 || height <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int x = overlay->position_x;
|
||||||
|
int y = overlay->position_y;
|
||||||
|
if (x < 0) {
|
||||||
|
x = (geometry.width - width) / 2;
|
||||||
|
}
|
||||||
|
if (y < 0) {
|
||||||
|
y = geometry.height - height - overlay->bottom_margin;
|
||||||
|
}
|
||||||
|
if (x < 0) {
|
||||||
|
x = 0;
|
||||||
|
}
|
||||||
|
if (y < 0) {
|
||||||
|
y = 0;
|
||||||
|
}
|
||||||
|
if (x + width > geometry.width) {
|
||||||
|
x = MAX(0, geometry.width - width);
|
||||||
|
}
|
||||||
|
if (y + height > geometry.height) {
|
||||||
|
y = MAX(0, geometry.height - height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay->layer_shell) {
|
||||||
|
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_LEFT, TRUE);
|
||||||
|
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_TOP, TRUE);
|
||||||
|
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_RIGHT, FALSE);
|
||||||
|
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, FALSE);
|
||||||
|
gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_LEFT, x);
|
||||||
|
gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_TOP, y);
|
||||||
|
gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_RIGHT, 0);
|
||||||
|
gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, 0);
|
||||||
|
} else if (overlay->x11) {
|
||||||
|
Display *display = GDK_SURFACE_XDISPLAY(surface);
|
||||||
|
Window xid = GDK_SURFACE_XID(surface);
|
||||||
|
int root_x = geometry.x + x;
|
||||||
|
int root_y = geometry.y + y;
|
||||||
|
XSizeHints *size_hints = XAllocSizeHints();
|
||||||
|
if (size_hints != NULL) {
|
||||||
|
size_hints->flags = USPosition | PPosition;
|
||||||
|
size_hints->x = root_x;
|
||||||
|
size_hints->y = root_y;
|
||||||
|
XSetWMNormalHints(display, xid, size_hints);
|
||||||
|
XFree(size_hints);
|
||||||
|
}
|
||||||
|
XMoveWindow(display, xid, root_x, root_y);
|
||||||
|
XFlush(display);
|
||||||
|
}
|
||||||
|
captioneer_update_input_region(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean captioneer_reposition_x11(gpointer data) {
|
||||||
|
captioneer_apply_placement(data);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void captioneer_surface_size_changed(GObject *object, GParamSpec *spec, gpointer data) {
|
||||||
|
(void)object;
|
||||||
|
(void)spec;
|
||||||
|
g_idle_add(captioneer_reposition_x11, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean captioneer_ctrl_held(GdkEvent *event) {
|
||||||
|
return (gdk_event_get_modifier_state(event) & GDK_CONTROL_MASK) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void captioneer_drag_begin(GtkGestureDrag *gesture, double x, double y, gpointer data) {
|
||||||
|
(void)x;
|
||||||
|
(void)y;
|
||||||
|
CaptioneerOverlay *overlay = data;
|
||||||
|
GdkEvent *event = gtk_event_controller_get_current_event(GTK_EVENT_CONTROLLER(gesture));
|
||||||
|
if (event == NULL || !captioneer_ctrl_held(event)) {
|
||||||
|
gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_DENIED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
||||||
cairo_region_t *empty = cairo_region_create();
|
if (surface == NULL) {
|
||||||
gdk_surface_set_input_region(surface, empty);
|
gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_DENIED);
|
||||||
cairo_region_destroy(empty);
|
return;
|
||||||
|
}
|
||||||
|
overlay->dragging = TRUE;
|
||||||
|
captioneer_update_input_region(overlay);
|
||||||
|
gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED);
|
||||||
|
|
||||||
|
GdkMonitor *monitor = captioneer_overlay_monitor(overlay);
|
||||||
|
GdkRectangle geometry = {0};
|
||||||
|
if (monitor != NULL) {
|
||||||
|
gdk_monitor_get_geometry(monitor, &geometry);
|
||||||
|
}
|
||||||
|
int width = gdk_surface_get_width(surface);
|
||||||
|
int height = gdk_surface_get_height(surface);
|
||||||
|
int origin_x = overlay->position_x;
|
||||||
|
int origin_y = overlay->position_y;
|
||||||
|
if (origin_x < 0) {
|
||||||
|
origin_x = (geometry.width - width) / 2;
|
||||||
|
}
|
||||||
|
if (origin_y < 0) {
|
||||||
|
origin_y = geometry.height - height - overlay->bottom_margin;
|
||||||
|
}
|
||||||
|
overlay->drag_origin_x = origin_x;
|
||||||
|
overlay->drag_origin_y = origin_y;
|
||||||
|
gtk_gesture_drag_get_start_point(gesture, &overlay->drag_start_px, &overlay->drag_start_py);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void captioneer_drag_update(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer data) {
|
||||||
|
CaptioneerOverlay *overlay = data;
|
||||||
|
if (!overlay->dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(void)gesture;
|
||||||
|
overlay->position_x = overlay->drag_origin_x + (int)offset_x;
|
||||||
|
overlay->position_y = overlay->drag_origin_y + (int)offset_y;
|
||||||
|
captioneer_apply_placement(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void captioneer_drag_end(GtkGestureDrag *gesture, double offset_x, double offset_y, gpointer data) {
|
||||||
|
(void)gesture;
|
||||||
|
CaptioneerOverlay *overlay = data;
|
||||||
|
if (!overlay->dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlay->position_x = overlay->drag_origin_x + (int)offset_x;
|
||||||
|
overlay->position_y = overlay->drag_origin_y + (int)offset_y;
|
||||||
|
overlay->dragging = FALSE;
|
||||||
|
captioneer_apply_placement(overlay);
|
||||||
|
captioneerOverlayPositionChanged(overlay->position_x, overlay->position_y);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void captioneer_apply_x11_hints(CaptioneerOverlay *overlay) {
|
static void captioneer_apply_x11_hints(CaptioneerOverlay *overlay) {
|
||||||
@@ -177,51 +373,11 @@ static void captioneer_apply_x11_hints(CaptioneerOverlay *overlay) {
|
|||||||
XFlush(display);
|
XFlush(display);
|
||||||
}
|
}
|
||||||
|
|
||||||
static gboolean captioneer_reposition_x11(gpointer data) {
|
|
||||||
CaptioneerOverlay *overlay = data;
|
|
||||||
if (!overlay->x11 || overlay->monitor == NULL) {
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
|
||||||
if (surface == NULL) {
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
GdkRectangle geometry;
|
|
||||||
gdk_monitor_get_geometry(overlay->monitor, &geometry);
|
|
||||||
int width = gdk_surface_get_width(surface);
|
|
||||||
int height = gdk_surface_get_height(surface);
|
|
||||||
if (width <= 1 || height <= 1) {
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
int bottom_margin = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(overlay->window), "bottom-margin"));
|
|
||||||
int x = geometry.x + (geometry.width - width) / 2;
|
|
||||||
int y = geometry.y + geometry.height - height - bottom_margin;
|
|
||||||
Display *display = GDK_SURFACE_XDISPLAY(surface);
|
|
||||||
Window xid = GDK_SURFACE_XID(surface);
|
|
||||||
XSizeHints *size_hints = XAllocSizeHints();
|
|
||||||
if (size_hints != NULL) {
|
|
||||||
size_hints->flags = USPosition | PPosition;
|
|
||||||
size_hints->x = x;
|
|
||||||
size_hints->y = y;
|
|
||||||
XSetWMNormalHints(display, xid, size_hints);
|
|
||||||
XFree(size_hints);
|
|
||||||
}
|
|
||||||
XMoveWindow(display, xid, x, y);
|
|
||||||
XFlush(display);
|
|
||||||
return G_SOURCE_REMOVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void captioneer_surface_size_changed(GObject *object, GParamSpec *spec, gpointer data) {
|
|
||||||
(void)object;
|
|
||||||
(void)spec;
|
|
||||||
g_idle_add(captioneer_reposition_x11, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void captioneer_update_window_visibility(CaptioneerOverlay *overlay) {
|
static void captioneer_update_window_visibility(CaptioneerOverlay *overlay) {
|
||||||
gboolean visible = overlay->final_visible || overlay->preview_visible;
|
gboolean visible = overlay->final_visible || overlay->preview_visible;
|
||||||
gtk_widget_set_visible(overlay->bubble, visible);
|
gtk_widget_set_visible(overlay->bubble, visible);
|
||||||
gtk_widget_set_visible(GTK_WIDGET(overlay->window), visible);
|
gtk_widget_set_visible(GTK_WIDGET(overlay->window), visible);
|
||||||
if (visible && overlay->x11) {
|
if (visible) {
|
||||||
g_idle_add(captioneer_reposition_x11, overlay);
|
g_idle_add(captioneer_reposition_x11, overlay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,9 +392,7 @@ static gboolean captioneer_apply_update(gpointer data) {
|
|||||||
|
|
||||||
overlay->preview_visible = update->preview_text[0] != '\0';
|
overlay->preview_visible = update->preview_text[0] != '\0';
|
||||||
if (overlay->preview_visible) {
|
if (overlay->preview_visible) {
|
||||||
char *preview = g_strdup_printf("… %s", update->preview_text);
|
gtk_label_set_text(GTK_LABEL(overlay->preview_label), update->preview_text);
|
||||||
gtk_label_set_text(GTK_LABEL(overlay->preview_label), preview);
|
|
||||||
g_free(preview);
|
|
||||||
} else {
|
} else {
|
||||||
gtk_label_set_text(GTK_LABEL(overlay->preview_label), "");
|
gtk_label_set_text(GTK_LABEL(overlay->preview_label), "");
|
||||||
}
|
}
|
||||||
@@ -287,6 +441,8 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
|||||||
int max_width,
|
int max_width,
|
||||||
const char *monitor_selection,
|
const char *monitor_selection,
|
||||||
int click_through,
|
int click_through,
|
||||||
|
int position_x,
|
||||||
|
int position_y,
|
||||||
char **warning_message,
|
char **warning_message,
|
||||||
char **error_message
|
char **error_message
|
||||||
) {
|
) {
|
||||||
@@ -315,6 +471,10 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
|||||||
CaptioneerOverlay *overlay = g_new0(CaptioneerOverlay, 1);
|
CaptioneerOverlay *overlay = g_new0(CaptioneerOverlay, 1);
|
||||||
overlay->loop = g_main_loop_new(NULL, FALSE);
|
overlay->loop = g_main_loop_new(NULL, FALSE);
|
||||||
overlay->x11 = x11;
|
overlay->x11 = x11;
|
||||||
|
overlay->click_through = click_through != 0;
|
||||||
|
overlay->bottom_margin = bottom_margin;
|
||||||
|
overlay->position_x = position_x;
|
||||||
|
overlay->position_y = position_y;
|
||||||
|
|
||||||
char *monitor_error = NULL;
|
char *monitor_error = NULL;
|
||||||
if (wayland && g_strcmp0(monitor_selection, "auto") == 0) {
|
if (wayland && g_strcmp0(monitor_selection, "auto") == 0) {
|
||||||
@@ -336,20 +496,17 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
|||||||
gtk_window_set_resizable(overlay->window, FALSE);
|
gtk_window_set_resizable(overlay->window, FALSE);
|
||||||
gtk_widget_set_focusable(GTK_WIDGET(overlay->window), FALSE);
|
gtk_widget_set_focusable(GTK_WIDGET(overlay->window), FALSE);
|
||||||
gtk_widget_add_css_class(GTK_WIDGET(overlay->window), "captioneer-overlay");
|
gtk_widget_add_css_class(GTK_WIDGET(overlay->window), "captioneer-overlay");
|
||||||
g_object_set_data(G_OBJECT(overlay->window), "bottom-margin", GINT_TO_POINTER(bottom_margin));
|
|
||||||
g_signal_connect(overlay->window, "close-request", G_CALLBACK(captioneer_window_close), overlay);
|
g_signal_connect(overlay->window, "close-request", G_CALLBACK(captioneer_window_close), overlay);
|
||||||
|
|
||||||
if (wayland && gtk_layer_is_supported()) {
|
if (wayland && gtk_layer_is_supported()) {
|
||||||
|
overlay->layer_shell = TRUE;
|
||||||
gtk_layer_init_for_window(overlay->window);
|
gtk_layer_init_for_window(overlay->window);
|
||||||
gtk_layer_set_namespace(overlay->window, "captioneer");
|
gtk_layer_set_namespace(overlay->window, "captioneer");
|
||||||
gtk_layer_set_layer(overlay->window, GTK_LAYER_SHELL_LAYER_OVERLAY);
|
gtk_layer_set_layer(overlay->window, GTK_LAYER_SHELL_LAYER_OVERLAY);
|
||||||
// Stretch across the bottom edge so the compositor pins the surface
|
// Pin with LEFT+TOP margins so placement is exact. Stretching LEFT+RIGHT
|
||||||
// there (single-edge anchoring can leave it floating mid-screen on
|
// made Plasma size the surface oddly and parked captions mid-screen.
|
||||||
// Plasma). The bubble itself stays content-width and centered.
|
|
||||||
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_LEFT, TRUE);
|
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_LEFT, TRUE);
|
||||||
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_RIGHT, TRUE);
|
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_TOP, TRUE);
|
||||||
gtk_layer_set_anchor(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, TRUE);
|
|
||||||
gtk_layer_set_margin(overlay->window, GTK_LAYER_SHELL_EDGE_BOTTOM, bottom_margin);
|
|
||||||
gtk_layer_set_exclusive_zone(overlay->window, 0);
|
gtk_layer_set_exclusive_zone(overlay->window, 0);
|
||||||
gtk_layer_set_keyboard_mode(overlay->window, GTK_LAYER_SHELL_KEYBOARD_MODE_NONE);
|
gtk_layer_set_keyboard_mode(overlay->window, GTK_LAYER_SHELL_KEYBOARD_MODE_NONE);
|
||||||
if (overlay->monitor != NULL) {
|
if (overlay->monitor != NULL) {
|
||||||
@@ -384,7 +541,7 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
|||||||
overlay->bubble = box;
|
overlay->bubble = box;
|
||||||
gtk_widget_add_css_class(box, "captioneer-bubble");
|
gtk_widget_add_css_class(box, "captioneer-bubble");
|
||||||
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
|
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
|
||||||
gtk_widget_set_valign(box, GTK_ALIGN_END);
|
gtk_widget_set_valign(box, GTK_ALIGN_START);
|
||||||
gtk_widget_set_hexpand(box, FALSE);
|
gtk_widget_set_hexpand(box, FALSE);
|
||||||
gtk_widget_set_vexpand(box, FALSE);
|
gtk_widget_set_vexpand(box, FALSE);
|
||||||
gtk_widget_set_size_request(box, effective_width, -1);
|
gtk_widget_set_size_request(box, effective_width, -1);
|
||||||
@@ -417,6 +574,14 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
|||||||
gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview");
|
gtk_widget_add_css_class(overlay->preview_label, "captioneer-preview");
|
||||||
gtk_window_set_child(overlay->window, box);
|
gtk_window_set_child(overlay->window, box);
|
||||||
|
|
||||||
|
GtkGesture *drag = gtk_gesture_drag_new();
|
||||||
|
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), GDK_BUTTON_PRIMARY);
|
||||||
|
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
|
||||||
|
g_signal_connect(drag, "drag-begin", G_CALLBACK(captioneer_drag_begin), overlay);
|
||||||
|
g_signal_connect(drag, "drag-update", G_CALLBACK(captioneer_drag_update), overlay);
|
||||||
|
g_signal_connect(drag, "drag-end", G_CALLBACK(captioneer_drag_end), overlay);
|
||||||
|
gtk_widget_add_controller(overlay->bubble, GTK_EVENT_CONTROLLER(drag));
|
||||||
|
|
||||||
char *font_family_rule = captioneer_font_family_rule(font_family);
|
char *font_family_rule = captioneer_font_family_rule(font_family);
|
||||||
char *css = g_strdup_printf(
|
char *css = g_strdup_printf(
|
||||||
"window.captioneer-overlay { background-color: transparent; box-shadow: none; }"
|
"window.captioneer-overlay { background-color: transparent; box-shadow: none; }"
|
||||||
@@ -433,15 +598,20 @@ static CaptioneerOverlay *captioneer_overlay_new(
|
|||||||
|
|
||||||
gtk_widget_realize(GTK_WIDGET(overlay->window));
|
gtk_widget_realize(GTK_WIDGET(overlay->window));
|
||||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(overlay->window));
|
||||||
if (click_through) {
|
if (overlay->click_through) {
|
||||||
captioneer_set_input_passthrough(overlay, warnings);
|
if (!gdk_display_supports_input_shapes(display)) {
|
||||||
|
captioneer_append_warning(warnings, "This display backend does not support pointer click-through.");
|
||||||
|
} else {
|
||||||
|
captioneer_update_input_region(overlay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (x11) {
|
if (x11) {
|
||||||
captioneer_apply_x11_hints(overlay);
|
captioneer_apply_x11_hints(overlay);
|
||||||
g_signal_connect(surface, "notify::width", G_CALLBACK(captioneer_surface_size_changed), overlay);
|
|
||||||
g_signal_connect(surface, "notify::height", G_CALLBACK(captioneer_surface_size_changed), overlay);
|
|
||||||
}
|
}
|
||||||
|
g_signal_connect(surface, "notify::width", G_CALLBACK(captioneer_surface_size_changed), overlay);
|
||||||
|
g_signal_connect(surface, "notify::height", G_CALLBACK(captioneer_surface_size_changed), overlay);
|
||||||
gtk_widget_set_visible(GTK_WIDGET(overlay->window), FALSE);
|
gtk_widget_set_visible(GTK_WIDGET(overlay->window), FALSE);
|
||||||
|
captioneer_apply_placement(overlay);
|
||||||
|
|
||||||
if (warnings->len > 0) {
|
if (warnings->len > 0) {
|
||||||
*warning_message = g_string_free(warnings, FALSE);
|
*warning_message = g_string_free(warnings, FALSE);
|
||||||
@@ -516,6 +686,28 @@ type Sink struct {
|
|||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
positionSaverMu sync.Mutex
|
||||||
|
positionSaver func(x, y int)
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetPositionSaver registers a callback invoked when the user finishes a CTRL-drag.
|
||||||
|
func SetPositionSaver(save func(x, y int)) {
|
||||||
|
positionSaverMu.Lock()
|
||||||
|
positionSaver = save
|
||||||
|
positionSaverMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
//export captioneerOverlayPositionChanged
|
||||||
|
func captioneerOverlayPositionChanged(x, y C.int) {
|
||||||
|
positionSaverMu.Lock()
|
||||||
|
save := positionSaver
|
||||||
|
positionSaverMu.Unlock()
|
||||||
|
if save != nil {
|
||||||
|
save(int(x), int(y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func New(settings config.OverlaySettings) (*Sink, string, error) {
|
func New(settings config.OverlaySettings) (*Sink, string, error) {
|
||||||
backend := C.CString(string(settings.Backend))
|
backend := C.CString(string(settings.Backend))
|
||||||
fontFamily := C.CString(settings.FontFamily)
|
fontFamily := C.CString(settings.FontFamily)
|
||||||
@@ -535,6 +727,8 @@ func New(settings config.OverlaySettings) (*Sink, string, error) {
|
|||||||
C.int(settings.MaxWidth),
|
C.int(settings.MaxWidth),
|
||||||
monitor,
|
monitor,
|
||||||
boolInt(settings.ClickThrough),
|
boolInt(settings.ClickThrough),
|
||||||
|
C.int(settings.PositionX),
|
||||||
|
C.int(settings.PositionY),
|
||||||
&warningMessage,
|
&warningMessage,
|
||||||
&errorMessage,
|
&errorMessage,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user