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.
This commit is contained in:
2026-07-17 19:55:00 +03:00
parent 41867429a5
commit 5b4a0d0785
6 changed files with 313 additions and 70 deletions
+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" -- \
+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,
) )