2 Commits
Author SHA1 Message Date
kato d6fda83397 docs: add manual desktop smoke test instructions to README and update CI test script
Tests / Go and GTK tests (push) Failing after 1m57s
- Introduce a new section in README detailing the manual desktop smoke test process for the application.
- Include commands for setting up and running the smoke test, along with expected outcomes and logging details.
- Update the CI test script to include the new smoke test script in the shellcheck validation process.
2026-07-17 20:07:38 +03:00
kato 5b4a0d0785 feat(overlay): add position customization for overlay placement
- Introduce PositionX and PositionY fields in OverlaySettings to allow manual positioning of the overlay.
- Update validation to ensure PositionX and PositionY are not less than -1.
- Modify diskConfig to include new position fields for JSON serialization.
- Implement functionality to save overlay position during drag events in the GUI.
- Enhance overlay placement logic to respect user-defined positions while ensuring proper display behavior.
2026-07-17 19:55:00 +03:00
11 changed files with 792 additions and 71 deletions
+12
View File
@@ -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.
+122
View File
@@ -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 ~1520s 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
View File
@@ -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
+11 -6
View File
@@ -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" -- \
+190
View File
@@ -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
+152
View File
@@ -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
}
+12
View File
@@ -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
} }
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+256 -62
View File
@@ -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,
) )