From 456881b3f953663c6292154ab2871a720980dd50 Mon Sep 17 00:00:00 2001 From: Youwenqwq Date: Fri, 2 Oct 2026 02:01:42 +0800 Subject: [PATCH 1/2] fix(wayland): add downgrade path for Mutter compositor, correct README description --- README.md | 6 +- src/frontend/view.rs | 107 +++++++++++++++++++++---------- src/frontend/view/positioning.rs | 83 +++++++++++++++++++++++- src/test/overlay_view_test.rs | 21 ++++++ 4 files changed, 179 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 331ae59..921009c 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ FloatLyrics 是一款面向 Linux Wayland 的 MPRIS 悬浮歌词应用。它会 | 组件 | 要求 | |---|---| -| 桌面会话 | Linux Wayland,合成器支持 layer-shell | +| 桌面会话 | Linux Wayland;悬浮覆盖层需要合成器支持 layer-shell,否则退化为普通窗口 | | 播放器 | 提供 `PlaybackStatus`、`Position` 和含标题 `Metadata` 的 MPRIS 播放器 | | 运行库 | GTK 4.12 或更高版本、gtk4-layer-shell、WebKitGTK 6.0 | -FloatLyrics 依赖 layer-shell,目前不支持 X11。可运行 `echo "$XDG_SESSION_TYPE"` 检查当前会话类型。 +FloatLyrics 依赖 layer-shell 实现悬浮覆盖层,目前不支持 X11。可运行 `echo "$XDG_SESSION_TYPE"` 检查当前会话类型。 -已知兼容的合成器包括 GNOME(Mutter)、KDE(KWin)、Hyprland 和 Sway;其他支持 layer-shell 的合成器也可能正常工作。 +KDE(KWin)、Hyprland 和 Sway 等合成器原生支持 layer-shell;GNOME(Mutter)不支持该协议,浮窗会退化为可拖拽的普通窗口,点击穿透与屏幕吸附不可用。 ## 安装 diff --git a/src/frontend/view.rs b/src/frontend/view.rs index 55cbb42..8d8bc67 100644 --- a/src/frontend/view.rs +++ b/src/frontend/view.rs @@ -98,7 +98,17 @@ pub(super) fn build( about: Rc, sender: relm4::Sender, ) -> OverlayView { - let drag_mode = desktop_drag_mode(std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref()); + let layer_shell_supported = gtk4_layer_shell::is_supported(); + if !layer_shell_supported { + tracing::warn!( + "the compositor does not support the layer shell protocol; \ + falling back to a plain draggable window" + ); + } + let drag_mode = overlay_drag_mode( + layer_shell_supported, + std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(), + ); let panel_width = compact_panel_width(config.window.width); let initial_placement = if config.window.remember_position { config.window.position.map(WindowPlacement::from_position) @@ -117,34 +127,41 @@ pub(super) fn build( window.set_decorated(false); window.set_resizable(false); - window.init_layer_shell(); - window.set_namespace(Some("floatlyrics")); - window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::None); - window.set_anchor(Edge::Bottom, true); - window.set_anchor(Edge::Left, true); - // KWin can apply desktop effects whenever a layer surface moves. On KDE, - // keep the surface fixed to the output and drag only its child panel. - window.set_anchor(Edge::Right, drag_mode.is_internal()); - window.set_anchor(Edge::Top, drag_mode.is_internal()); - if !drag_mode.is_internal() { - window.set_margin( - Edge::Left, - initial_placement - .and_then(|placement| left_position_for_width(window, &placement, panel_width)) - .or_else(|| initial_x(panel_width)) - .unwrap_or_default(), - ); - window.set_margin( - Edge::Bottom, - initial_placement - .and_then(|placement| { - bottom_margin_from_placement(window, &placement, panel_width, fallback_height) - }) - .unwrap_or_else(|| effective_bottom_margin(config)), - ); + if drag_mode.uses_layer_shell() { + window.init_layer_shell(); + window.set_namespace(Some("floatlyrics")); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::None); + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Left, true); + // KWin can apply desktop effects whenever a layer surface moves. On KDE, + // keep the surface fixed to the output and drag only its child panel. + window.set_anchor(Edge::Right, drag_mode.is_internal()); + window.set_anchor(Edge::Top, drag_mode.is_internal()); + if !drag_mode.is_internal() { + window.set_margin( + Edge::Left, + initial_placement + .and_then(|placement| left_position_for_width(window, &placement, panel_width)) + .or_else(|| initial_x(panel_width)) + .unwrap_or_default(), + ); + window.set_margin( + Edge::Bottom, + initial_placement + .and_then(|placement| { + bottom_margin_from_placement( + window, + &placement, + panel_width, + fallback_height, + ) + }) + .unwrap_or_else(|| effective_bottom_margin(config)), + ); + } + window.set_exclusive_zone(-1); } - window.set_exclusive_zone(-1); window.add_css_class("floating-window"); super::style::install( @@ -199,6 +216,10 @@ pub(super) fn build( }, move |placement| placement_view.set_overlay_placement(snap_classes(&placement)), move |position| { + if !drag_mode.uses_layer_shell() { + // A compositor-driven move reports no position to persist. + return; + } let _ = sender.send(AppMsg::WindowMoved(position)); if drag_mode.is_internal() { let (Some(window), Some(content)) = @@ -235,7 +256,9 @@ pub(super) fn build( panel_width, fallback_height, ); - setup_input_region(window, &content); + if !placement.uses_native_window() { + setup_input_region(window, &content); + } gtk::glib::idle_add_local_once(move || { let picked = web_stack.pick(16.0, 16.0, gtk::PickFlags::DEFAULT); if picked @@ -290,7 +313,9 @@ fn apply_panel_width( content.set_width_request(width); lyrics_viewport.set_width_request(width); reposition_for_width(window, stage, content, placement, width); - setup_input_region(window, content); + if !placement.uses_native_window() { + setup_input_region(window, content); + } } fn set_status_lyrics(floating: &OverlayView, message: &str, key: Text) { @@ -371,7 +396,7 @@ impl OverlayView { width, fallback_height, ); - } else { + } else if self.placement.uses_layer_margins() { self.window.set_margin( Edge::Bottom, bottom_margin_from_placement( @@ -413,12 +438,19 @@ impl OverlayView { width, fallback_height, ); - } else if let Some(bottom_margin) = - bottom_margin_from_placement(&window, &placement.current(), width, fallback_height) + } else if placement.uses_layer_margins() + && let Some(bottom_margin) = bottom_margin_from_placement( + &window, + &placement.current(), + width, + fallback_height, + ) { window.set_margin(Edge::Bottom, bottom_margin); } - setup_input_region(&window, &content); + if !placement.uses_native_window() { + setup_input_region(&window, &content); + } }); } @@ -540,6 +572,13 @@ fn track_offset_label(offset_ms: i64, unit: &str) -> String { } } +fn overlay_drag_mode(layer_shell_supported: bool, desktop: Option<&str>) -> DragMode { + if !layer_shell_supported { + return DragMode::ToplevelWindow; + } + desktop_drag_mode(desktop) +} + fn desktop_drag_mode(desktop: Option<&str>) -> DragMode { if desktop.is_some_and(|desktop| { desktop diff --git a/src/frontend/view/positioning.rs b/src/frontend/view/positioning.rs index 9a8ecc0..a3447d3 100644 --- a/src/frontend/view/positioning.rs +++ b/src/frontend/view/positioning.rs @@ -35,12 +35,18 @@ struct PlacedContent { pub(super) enum DragMode { LayerSurface, InternalPanel, + /// No layer shell protocol: a plain toplevel the compositor moves. + ToplevelWindow, } impl DragMode { pub(super) fn is_internal(self) -> bool { self == Self::InternalPanel } + + pub(super) fn uses_layer_shell(self) -> bool { + self != Self::ToplevelWindow + } } impl PlacementState { @@ -68,6 +74,14 @@ impl PlacementState { self.mode.is_internal() } + pub(super) fn uses_layer_margins(&self) -> bool { + self.mode == DragMode::LayerSurface + } + + pub(super) fn uses_native_window(&self) -> bool { + self.mode == DragMode::ToplevelWindow + } + fn set(&self, placement: WindowPlacement, left: i32, top: i32) { *self.content.borrow_mut() = PlacedContent { placement, @@ -94,6 +108,11 @@ pub(super) fn attach_floating_drag( on_placement_changed: impl Fn(WindowPlacement) + 'static, on_drag_end: impl Fn(WindowPosition) + 'static, ) -> PlacementState { + if layout.mode == DragMode::ToplevelWindow { + // The layer modes mount the panel below; the plain window needs it too. + layout.stage.put(content, 0.0, 0.0); + return attach_toplevel_move(window, content, drag_handle, on_placement_changed); + } let FloatingDragLayout { stage, fallback_width, @@ -243,6 +262,68 @@ pub(super) fn attach_floating_drag( placement } +/// Plain-window fallback: the compositor moves the toplevel, so the drag only +/// hands the pointer to it and there is no placement to track or persist. +fn attach_toplevel_move( + window: >k::ApplicationWindow, + content: >k::Box, + drag_handle: >k::Box, + on_placement_changed: impl Fn(WindowPlacement) + 'static, +) -> PlacementState { + let placement = WindowPlacement::default(); + on_placement_changed(placement); + let gesture = gtk::GestureDrag::new(); + drag_handle.set_cursor_from_name(Some("grab")); + { + let window = window.downgrade(); + let drag_handle = drag_handle.downgrade(); + gesture.connect_drag_begin(move |gesture, _, _| { + let (Some(window), Some(drag_handle)) = (window.upgrade(), drag_handle.upgrade()) + else { + return; + }; + drag_handle.set_cursor_from_name(Some("grabbing")); + let Some(surface) = window.surface() else { + return; + }; + let Some(device) = gesture.current_event_device() else { + return; + }; + let Some((x, y, _)) = surface.device_position(&device) else { + return; + }; + let Ok(toplevel) = surface.downcast::() else { + return; + }; + toplevel.begin_move( + &device, + gesture.current_button() as i32, + x, + y, + gesture.current_event_time(), + ); + }); + } + { + let drag_handle = drag_handle.downgrade(); + gesture.connect_drag_end(move |_, _, _| { + if let Some(drag_handle) = drag_handle.upgrade() { + drag_handle.set_cursor_from_name(Some("grab")); + } + }); + } + { + let drag_handle = drag_handle.downgrade(); + gesture.connect_cancel(move |_, _| { + if let Some(drag_handle) = drag_handle.upgrade() { + drag_handle.set_cursor_from_name(Some("grab")); + } + }); + } + content.add_controller(gesture); + PlacementState::new(placement, 0, 0, DragMode::ToplevelWindow) +} + pub(super) fn initial_x(window_width: i32) -> Option { let monitor = first_monitor()?; Some(centered_position(monitor.geometry().width(), window_width)) @@ -290,7 +371,7 @@ pub(super) fn reposition_for_width( } stage.move_(content, left as f64, top as f64); placement.set(current, left, top); - } else { + } else if placement.uses_layer_margins() { window.set_margin(Edge::Left, left); } } diff --git a/src/test/overlay_view_test.rs b/src/test/overlay_view_test.rs index cb8c059..98185b7 100644 --- a/src/test/overlay_view_test.rs +++ b/src/test/overlay_view_test.rs @@ -17,3 +17,24 @@ fn kde_uses_internal_drag_to_bypass_compositor_move_effects() { assert_eq!(desktop_drag_mode(Some("GNOME")), DragMode::LayerSurface); assert_eq!(desktop_drag_mode(None), DragMode::LayerSurface); } + +#[test] +fn compositor_without_layer_shell_falls_back_to_a_plain_window() { + assert_eq!( + overlay_drag_mode(false, Some("KDE")), + DragMode::ToplevelWindow + ); + assert_eq!( + overlay_drag_mode(false, Some("GNOME")), + DragMode::ToplevelWindow + ); + assert_eq!(overlay_drag_mode(false, None), DragMode::ToplevelWindow); + assert_eq!( + overlay_drag_mode(true, Some("KDE")), + DragMode::InternalPanel + ); + assert_eq!( + overlay_drag_mode(true, Some("GNOME")), + DragMode::LayerSurface + ); +} From bf1e4a061e1d1e62e8acd6aef03ba56d028eaa71 Mon Sep 17 00:00:00 2001 From: ChouChiu Date: Fri, 2 Oct 2026 19:51:16 +0800 Subject: [PATCH 2/2] fix(ui): polish the plain-window fallback for compositors without layer shell Localize the overlay window title, which the fallback now shows in the window switcher, and keep it in sync with the language. Share the cursor reset between both drag paths, drop a guard the fallback can never reach, and document what the fallback gives up in README and AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 ++++- README.md | 6 ++--- data/locale/en.json | 1 + data/locale/zh-CN.json | 1 + data/locale/zh-TW.json | 1 + floatlyrics-core/src/i18n.rs | 1 + src/frontend/view.rs | 12 ++++++---- src/frontend/view/positioning.rs | 41 +++++++++++++------------------- 8 files changed, 35 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fc216a0..e9e2021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,7 +315,11 @@ discovery and is the preferred way to isolate catalogue tests. manual-selection precedence when changing search or cache behavior. - Relm4 initializes GTK; do not call `gtk::init()`. - The application is a Wayland layer-shell overlay and is not expected to run on - X11 or without compositor layer-shell support. + X11. When `gtk4_layer_shell::is_supported()` is false (GNOME's Mutter), the + overlay falls back to `DragMode::ToplevelWindow`: a plain undecorated window + the compositor moves, with no click-through input region, edge snapping, or + remembered position. Keep layer-shell calls and margin updates behind + `uses_layer_shell()` / `uses_layer_margins()` so that path never reaches them. - Before GTK initialization, `src/lib.rs` supplies default `GSK_RENDERER=gl` and `GTK_A11Y=none` values when unset. Preserve caller-provided values. diff --git a/README.md b/README.md index 921009c..96f29d7 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ FloatLyrics 是一款面向 Linux Wayland 的 MPRIS 悬浮歌词应用。它会 | 播放器 | 提供 `PlaybackStatus`、`Position` 和含标题 `Metadata` 的 MPRIS 播放器 | | 运行库 | GTK 4.12 或更高版本、gtk4-layer-shell、WebKitGTK 6.0 | -FloatLyrics 依赖 layer-shell 实现悬浮覆盖层,目前不支持 X11。可运行 `echo "$XDG_SESSION_TYPE"` 检查当前会话类型。 +FloatLyrics 依赖 layer-shell 实现悬浮覆盖层,目前不支持 X11;X11 下同样会退化为普通窗口,但不在支持范围内。可运行 `echo "$XDG_SESSION_TYPE"` 检查当前会话类型。 -KDE(KWin)、Hyprland 和 Sway 等合成器原生支持 layer-shell;GNOME(Mutter)不支持该协议,浮窗会退化为可拖拽的普通窗口,点击穿透与屏幕吸附不可用。 +KDE(KWin)、Hyprland 和 Sway 等合成器原生支持 layer-shell;GNOME(Mutter)不支持该协议,浮窗会退化为可拖拽的普通窗口,点击穿透、屏幕吸附与记住窗口位置不可用;可在窗口上按 `Alt+Space` 打开“置顶”与“在所有工作区显示” ## 安装 @@ -269,7 +269,7 @@ enabled = true ## 常见问题与限制 -- **浮窗没有出现:** 确认当前是 Wayland 会话、合成器支持 layer-shell,并且播放器正在通过 MPRIS 暴露播放状态。 +- **浮窗没有出现:** 确认当前是 Wayland 会话,并且播放器正在通过 MPRIS 暴露播放状态。 - **播放器没有被识别:** 运行 `busctl --user list` 检查 `org.mpris.MediaPlayer2.*` 名称,必要时调整 `player.preferred_players` 或 `player.ignored_players`。 - **歌词时间不准:** 使用浮窗按钮校准当前歌曲,或在设置中调整全局偏移 `lyrics.offset_ms`。播放器未正确更新 `Position` 或 `Metadata` 时可能无法可靠同步。 - **逐词高亮与演唱不完全一致:** 逐词时间只取自歌词源自己的逐字数据(AMLL TTML DB 的逐词 TTML、QQ 音乐 QRC、网易云逐字、汽水 KRC);只提供整行时间的歌词源(例如 LRC)不做逐词高亮,整行一起点亮。网易云的逐字歌词是它单独一份时间表,FloatLyrics 只取其中的时间、文字仍用它的整行版本,对不上的行(例如逐字版给某个词打了码)同样退回整行高亮。 diff --git a/data/locale/en.json b/data/locale/en.json index 605c0a6..ce60aaf 100644 --- a/data/locale/en.json +++ b/data/locale/en.json @@ -120,6 +120,7 @@ "SearchingProviders": "Searching lyrics sources…", "SelectCandidatePreview": "Select a candidate to preview its lyrics", "SettingsWindowTitle": "FloatLyrics Settings", + "OverlayWindowTitle": "FloatLyrics Lyrics", "ShowRomanization": "Show romanization", "ShowRomanizationDescription": "Generated locally from the original lyrics", "ChineseRomanization": "Chinese pronunciation", diff --git a/data/locale/zh-CN.json b/data/locale/zh-CN.json index 1af4a1e..0044506 100644 --- a/data/locale/zh-CN.json +++ b/data/locale/zh-CN.json @@ -120,6 +120,7 @@ "SearchingProviders": "正在搜索歌词源…", "SelectCandidatePreview": "选择候选歌词后将在这里预览", "SettingsWindowTitle": "FloatLyrics 设置", + "OverlayWindowTitle": "FloatLyrics 歌词", "ShowRomanization": "显示罗马音", "ShowRomanizationDescription": "始终根据原歌词在本地生成", "ChineseRomanization": "中文罗马音", diff --git a/data/locale/zh-TW.json b/data/locale/zh-TW.json index 2950566..83227df 100644 --- a/data/locale/zh-TW.json +++ b/data/locale/zh-TW.json @@ -120,6 +120,7 @@ "SearchingProviders": "正在搜尋歌詞來源…", "SelectCandidatePreview": "選擇候選歌詞後會在這裡預覽", "SettingsWindowTitle": "FloatLyrics 設定", + "OverlayWindowTitle": "FloatLyrics 歌詞", "ShowRomanization": "顯示羅馬拼音", "ShowRomanizationDescription": "一律根據原歌詞在本機產生", "ChineseRomanization": "中文羅馬拼音", diff --git a/floatlyrics-core/src/i18n.rs b/floatlyrics-core/src/i18n.rs index 1c62a50..f3c41b5 100644 --- a/floatlyrics-core/src/i18n.rs +++ b/floatlyrics-core/src/i18n.rs @@ -208,6 +208,7 @@ macro_rules! define_text_keys { define_text_keys!( SettingsWindowTitle, + OverlayWindowTitle, ChangesSavedAutomatically, Saved, SaveFailed, diff --git a/src/frontend/view.rs b/src/frontend/view.rs index 8d8bc67..03f4f05 100644 --- a/src/frontend/view.rs +++ b/src/frontend/view.rs @@ -123,7 +123,7 @@ pub(super) fn build( config.lyrics.apple_music_style, ); let fallback_height = fallback_panel_height(viewport_h); - window.set_title(Some("FloatLyrics Overlay")); + window.set_title(Some(i18n.text(Text::OverlayWindowTitle))); window.set_decorated(false); window.set_resizable(false); @@ -215,11 +215,9 @@ pub(super) fn build( mode: drag_mode, }, move |placement| placement_view.set_overlay_placement(snap_classes(&placement)), + // The plain-window fallback never calls this: a compositor-driven move + // reports no position to persist. move |position| { - if !drag_mode.uses_layer_shell() { - // A compositor-driven move reports no position to persist. - return; - } let _ = sender.send(AppMsg::WindowMoved(position)); if drag_mode.is_internal() { let (Some(window), Some(content)) = @@ -292,6 +290,10 @@ pub(super) fn build( { let overlay = overlay.clone(); i18n.subscribe(move |language| { + // Only the plain-window fallback shows this title to the user. + overlay + .window + .set_title(Some(language.text(Text::OverlayWindowTitle))); overlay.render_overlay_state(language); let static_status = overlay.state.static_status(); if let Some(key) = static_status { diff --git a/src/frontend/view/positioning.rs b/src/frontend/view/positioning.rs index a3447d3..fea0f10 100644 --- a/src/frontend/view/positioning.rs +++ b/src/frontend/view/positioning.rs @@ -232,23 +232,11 @@ pub(super) fn attach_floating_drag( { let placement = placement.clone(); - let drag_handle = drag_handle.downgrade(); gesture.connect_drag_end(move |_, _, _| { - if let Some(drag_handle) = drag_handle.upgrade() { - drag_handle.set_cursor_from_name(Some("grab")); - } on_drag_end(placement.current().position()); }); } - - { - let drag_handle = drag_handle.downgrade(); - gesture.connect_cancel(move |_, _| { - if let Some(drag_handle) = drag_handle.upgrade() { - drag_handle.set_cursor_from_name(Some("grab")); - } - }); - } + restore_grab_cursor(&gesture, drag_handle); if mode.is_internal() { // GestureDrag reports offsets in the controller widget's coordinate @@ -304,24 +292,27 @@ fn attach_toplevel_move( ); }); } - { + restore_grab_cursor(&gesture, drag_handle); + content.add_controller(gesture); + PlacementState::new(placement, 0, 0, DragMode::ToplevelWindow) +} + +/// Puts the open-hand cursor back on the drag handle when a drag ends or the +/// gesture is cancelled. +fn restore_grab_cursor(gesture: >k::GestureDrag, drag_handle: >k::Box) { + let restore = { let drag_handle = drag_handle.downgrade(); - gesture.connect_drag_end(move |_, _, _| { + move || { if let Some(drag_handle) = drag_handle.upgrade() { drag_handle.set_cursor_from_name(Some("grab")); } - }); - } + } + }; { - let drag_handle = drag_handle.downgrade(); - gesture.connect_cancel(move |_, _| { - if let Some(drag_handle) = drag_handle.upgrade() { - drag_handle.set_cursor_from_name(Some("grab")); - } - }); + let restore = restore.clone(); + gesture.connect_drag_end(move |_, _, _| restore()); } - content.add_controller(gesture); - PlacementState::new(placement, 0, 0, DragMode::ToplevelWindow) + gesture.connect_cancel(move |_, _| restore()); } pub(super) fn initial_x(window_width: i32) -> Option {