fix: respect mouse capture when routing hyperlink clicks (#5064)

refs #4874
This commit is contained in:
JJ Liebig
2026-10-08 01:30:46 +02:00
committed by GitHub
parent 4f296c295b
commit 4dc23bb15d
14 changed files with 323 additions and 60 deletions
@@ -350,6 +350,8 @@ description = "apply layout"
シェルプラグインは `HERDR_PLUGIN_CLICKED_URL` と `HERDR_PLUGIN_LINK_HANDLER_ID` も
読めます。ハンドラーは各プラグイン内でマニフェストの順にチェックされます。
リンクのクリックでプラグインを呼び出すのは、`ui.mouse_capture` が有効な場合だけです。一致するハンドラーが先に実行され、起動に失敗するとエラーを表示して停止します。一致するハンドラーがない場合、外部で開くのは HTTP(S) リンクだけです。キャプチャが無効な場合、外側のターミナルが通常のショートカットと警告で明示的なハイパーリンクを処理します。Windows Terminal の URL 検出の注意点は[マウス操作](/ja/docs/quick-start/#マウスを使う)を参照してください。
## ストレージ
v1 には Herdr が管理するプラグインストレージ API はありません。永続状態が必要な
@@ -19,7 +19,13 @@ Herdr はデフォルトのバックグラウンドセッションを起動す
Herdr はマウスネイティブなので、まずはクリックから始めてください。ペイン、タブ、ワークスペース、エージェントをクリックしてフォーカスします。分割境界をドラッグしてリサイズします。右クリックでコンテキストメニューが開き、ペインの分割やタブの作成ができます。テキストをドラッグ選択するとクリップボードにコピーされます。トークンをダブルクリックすると選択し、2 回目の押下を保持してドラッグすると単語単位で選択を広げられます。どちらもマウスを離すとコピーされます。コピーに Ctrl+C は不要です。
ターミナルが修飾キー付きクリックを Herdr に渡す場合、Ctrl+クリックでペイン内のリンクを開けます。OSC 8 ハイパーリンクと `http://` または `https://` の URL に対応し、折り返されたリンクの先頭や末尾が表示範囲外でも開けます。Ctrl を押しながらリンク上にポインターを移動すると、表示中の部分に下線が付きます。ポインターを外す、ペイン内容が変わる、または Ctrl の解放イベントを受け取ると下線は消えます。一部のターミナルでは、修飾キーの変化はマウス移動時にだけ報告されます。macOS では、マウスキャプチャが有効な間は Herdr が処理するペインリンクに Ctrl+クリックを使ってください。Cmd+クリックは Shift+Cmd+クリックや `ui.mouse_capture = false` といったターミナルネイティブのバイパス経路でのみ使えます。
ターミナルが修飾キー付きクリックを Herdr に渡す場合、Ctrl+クリックでペイン内のリンクを開けます。OSC 8 ハイパーリンクと `http://` または `https://` の URL に対応し、折り返されたリンクの先頭や末尾が表示範囲外でも開けます。Ctrl を押しながらリンク上にポインターを移動すると、表示中の部分に下線が付きます。ポインターを外す、ペイン内容が変わる、または Ctrl の解放イベントを受け取ると下線は消えます。一部のターミナルでは、修飾キーの変化はマウス移動時にだけ報告されます。
`ui.mouse_capture = true`(既定値)では、Herdr が Ctrl+クリックを処理します。有効なプラグインがリンクに一致すると、先に実行されます。ハンドラーの起動に失敗するとエラーを表示して停止します。一致しない `http://` と `https://` のリンクは、Herdr クライアントが動くマシンの既定ブラウザーで開きます。その他のリンクには一致するプラグインが必要です。このモードでは明示的なハイパーリンクを外側のターミナルに渡さないため、ラベル付きリンクをネイティブ操作で開けません。macOS の Command+クリックも同様です。
`ui.mouse_capture = false` では、明示的なハイパーリンクを外側のターミナルに渡します。そのターミナルの通常のショートカットと警告が使われ、Herdr のプラグインは実行されません。キャプチャを無効にすると、タブのクリックや分割境界のドラッグなど、Herdr のマウス操作も無効になります。
Windows Terminal は表示テキストから検出した URL を先に開く場合があります。それらを確実にプラグインへ渡すには、Windows Terminal の `settings.json` で `"experimental.detectURLs": false` を設定してください。全プロファイルに適用されます。Herdr はこの設定を変更しません。
`ui.right_click_passthrough_modifier` を設定すると、その修飾キー + 右クリックで、右クリック・ホールド・ドラッグのジェスチャーがマウスレポーティング対応のペインアプリに送られます。
@@ -368,6 +368,8 @@ name an action declared by the same plugin. Link handler actions receive
`HERDR_PLUGIN_CLICKED_URL` and `HERDR_PLUGIN_LINK_HANDLER_ID`. Handlers are
checked in manifest order inside each plugin.
Link clicks invoke plugins only while `ui.mouse_capture` is enabled. Matching handlers run first. A launch failure shows an error and stops. Without a matching handler, only HTTP(S) links open externally. When capture is disabled, the outer terminal handles explicit hyperlinks using its own shortcuts and warnings. See [mouse controls](/docs/quick-start/#use-the-mouse) for the Windows Terminal URL detection caveat.
## Storage
There is no Herdr-managed plugin storage API in v1. Plugins that need durable
@@ -19,7 +19,13 @@ When a session has no workspaces, Herdr opens one automatically. A workspace is
Herdr is mouse-native, so start by clicking panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard. Double-click a token to select it, or hold the second press and drag to extend the selection by whole words. Both gestures copy when you release the mouse. Copying does not require Ctrl+C.
Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and `http://` or `https://` URLs, including wrapped links whose beginning or end is outside the visible area. Hold Ctrl and move the pointer over a link to underline its visible segments. Hover highlighting clears when you move away, the pane content changes, or Herdr receives a Ctrl-release event; some terminals only report modifier changes with mouse movement. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`.
Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and `http://` or `https://` URLs, including wrapped links whose beginning or end is outside the visible area. Hold Ctrl and move the pointer over a link to underline its visible segments. Hover highlighting clears when you move away, the pane content changes, or Herdr receives a Ctrl-release event; some terminals only report modifier changes with mouse movement.
With `ui.mouse_capture = true` (the default), Herdr handles Ctrl-clicks. Matching enabled plugins run first. A handler launch failure shows an error and stops. Unmatched `http://` and `https://` links open in the default browser on the machine running the Herdr client. Other links need a matching plugin. Explicit hyperlinks are not advertised to the outer terminal in this mode, so native bypass clicks on labeled links are unavailable, including Command-click on macOS.
With `ui.mouse_capture = false`, explicit hyperlinks are passed to the outer terminal. Use its normal link shortcuts and warnings. These clicks do not invoke Herdr plugins. Disabling capture also disables Herdr's mouse controls, including clicking tabs and dragging split borders.
Windows Terminal can still intercept URLs it detects in visible text. For reliable plugin priority on those URLs, set `"experimental.detectURLs": false` in Windows Terminal's `settings.json`. This setting applies to all Windows Terminal profiles. Herdr does not change it.
If you configure `ui.right_click_passthrough_modifier`, that modifier plus right-click sends right-click, hold, and drag gestures to mouse-reporting pane apps. To make normal right-click go to one pane app, choose **Send right-clicks to pane** from that pane's menu or run `herdr pane input --current --right-click pane` inside it. Right-click the pane frame to reopen Herdr's menu.
@@ -312,6 +312,8 @@ description = "apply layout"
`link_handler_id`;shell 插件也可以读取 `HERDR_PLUGIN_CLICKED_URL` 和
`HERDR_PLUGIN_LINK_HANDLER_ID`。每个插件内的处理器按清单顺序检查。
只有启用 `ui.mouse_capture` 时,点击链接才会调用插件。匹配的处理器先执行,启动失败会显示错误并停止。没有匹配处理器时,只有 HTTP(S) 链接会在外部打开。关闭捕获后,外层终端使用自己的快捷键和警告处理显式超链接。Windows Terminal URL 检测的注意事项见[鼠标操作](/zh-cn/docs/quick-start/#使用鼠标)。
## 存储
v1 没有 Herdr 管理的插件存储 API。需要持久状态的插件应自己管理文件或
@@ -19,7 +19,13 @@ Herdr 会启动或连接到你的默认后台会话。你不需要管理 socket
Herdr 是鼠标原生的,所以从点击开始。点击窗格、标签页、工作区和智能体来聚焦它们。拖动分割边框来调整大小。右键打开上下文菜单,包括分割窗格和创建标签页。拖选文本即可复制到剪贴板。双击一个词可以选中它;按住第二次点击并拖动,会按整词扩展选择。两种操作都会在松开鼠标时复制。复制不需要 Ctrl+C。
当终端把带修饰键的点击传给 Herdr 时,Ctrl+点击可以打开窗格内的链接。这适用于 OSC 8 超链接及 `http://` 或 `https://` URL,包括首尾部分位于可见区域之外的换行链接。按住 Ctrl 并将指针移到链接上,会为可见部分添加下划线。移开指针、窗格内容变化或收到 Ctrl 释放事件时,高亮会清除;部分终端只在鼠标移动时报告修饰键变化。在 macOS 上,鼠标捕获开启时,请用 Ctrl+点击来触发 Herdr 处理的窗格链接;Cmd+点击只能通过终端原生的绕过路径使用,比如 Shift+Cmd+点击或 `ui.mouse_capture = false`。
当终端把带修饰键的点击传给 Herdr 时,Ctrl+点击可以打开窗格内的链接。这适用于 OSC 8 超链接及 `http://` 或 `https://` URL,包括首尾部分位于可见区域之外的换行链接。按住 Ctrl 并将指针移到链接上,会为可见部分添加下划线。移开指针、窗格内容变化或收到 Ctrl 释放事件时,高亮会清除;部分终端只在鼠标移动时报告修饰键变化。
`ui.mouse_capture = true`(默认)时,Herdr 处理 Ctrl+点击。匹配的已启用插件先执行。处理器启动失败会显示错误并停止。没有匹配插件的 `http://` 和 `https://` 链接在运行 Herdr 客户端的机器上用默认浏览器打开。其他链接需要匹配的插件。此模式不会把显式超链接传给外层终端,因此标签链接无法通过终端原生操作打开,包括 macOS 的 Command+点击。
`ui.mouse_capture = false` 时,显式超链接会传给外层终端,使用终端通常的快捷键和警告。这些点击不会调用 Herdr 插件。关闭捕获也会禁用 Herdr 的鼠标操作,包括点击标签页和拖动分割边框。
Windows Terminal 仍可能抢先打开从可见文本中检测到的 URL。要让插件可靠地优先处理这些 URL,请在 Windows Terminal 的 `settings.json` 中设置 `"experimental.detectURLs": false`。此设置适用于所有配置文件。Herdr 不会更改它。
如果配置了 `ui.right_click_passthrough_modifier`,该修饰键加右键会把右键点击、按住和拖动手势发送给启用鼠标上报的窗格应用。
+66 -1
View File
@@ -345,7 +345,7 @@ impl App {
Ok(handled) => handled,
Err(err) => {
tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler");
false
return encode_error(id, "plugin_link_failed", err);
}
},
None => false,
@@ -878,6 +878,71 @@ mod tests {
assert_eq!(response["error"]["code"], "stale_target");
}
#[tokio::test]
async fn pane_link_activation_reports_claimed_plugin_launch_failure() {
let mut app = test_app();
app.state.workspaces = vec![crate::workspace::Workspace::test_new("link-failure")];
app.state.ensure_test_terminals();
app.state.active = Some(0);
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let public_id = app.public_pane_id(0, pane_id).unwrap();
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 4);
runtime.test_process_pty_bytes(
b"\x1b]8;;obsidian://open?vault=meta\x1b\\Open note\x1b]8;;\x1b\\",
);
app.terminal_runtimes.insert(terminal_id, runtime);
let root = unique_temp_path("claimed-plugin-link-failure");
write_manifest_content(
&root,
r#"
id = "example.failed-link"
name = "Failed link"
version = "0.1.0"
min_herdr_version = "0.9.3"
platforms = ["linux", "macos", "windows"]
[[actions]]
id = "open"
title = "Open note"
command = ["never-launched"]
[[link_handlers]]
id = "note"
title = "Note"
pattern = "^obsidian://"
action = "open"
"#,
);
link_manifest(&mut app, &root);
app.state.plugin_commands_in_flight = runtime::MAX_PLUGIN_COMMANDS_IN_FLIGHT;
let response = app.handle_pane_link_activate(
"click".into(),
PaneLinkActivateParams {
pane_id: public_id,
viewport_row: 0,
col: 1,
content_revision: None,
offset_from_bottom: None,
},
);
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["error"]["code"], "plugin_link_failed");
assert!(response["error"]["message"]
.as_str()
.unwrap()
.contains("maximum concurrent plugin commands"));
assert!(
response.get("result").is_none(),
"a claim failure is not an unhandled link"
);
assert!(
rx.try_recv().is_err(),
"activation must not write to the PTY"
);
std::fs::remove_dir_all(root).expect("remove test plugin");
}
fn test_app() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
App::new(
+7 -2
View File
@@ -660,7 +660,8 @@ impl ClientShellState {
event.kind
== crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left)
});
let replay = (self.mode == ClientShellMode::Terminal
let replay = (self.config.mouse_capture
&& self.mode == ClientShellMode::Terminal
&& self.overlay.is_none()
&& self
.hits
@@ -688,7 +689,7 @@ impl ClientShellState {
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
url: Some(url),
handled: false,
}) if crate::app::actions::safe_web_url(&url).is_some() => {
}) if crate::app::actions::safe_web_url(&url).is_some() && replay.is_some() => {
self.url_click_consumes_until_up = completed_before_release;
(false, vec![ClientShellAction::OpenSafeWebUrl(url)])
}
@@ -699,6 +700,10 @@ impl ClientShellState {
self.set_endpoint_error("endpoint returned an unexpected link result");
(true, replay_action(replay))
}
Err(error) if error.code.as_deref() == Some("plugin_link_failed") => {
self.url_click_consumes_until_up = completed_before_release;
(true, Vec::new())
}
Err(error)
if matches!(
error.code.as_deref(),
+2 -1
View File
@@ -38,7 +38,8 @@ impl ClientShellState {
}
fn link_hover_allowed(&self) -> bool {
self.mode == ClientShellMode::Terminal
self.config.mouse_capture
&& self.mode == ClientShellMode::Terminal
&& self.overlay.is_none()
&& self.popup_terminal_id.is_none()
&& self.outer_focused != Some(false)
+2 -1
View File
@@ -945,7 +945,8 @@ impl ClientShellState {
if self.popup_terminal_id.is_some() {
return;
}
if !self.replaying_url_click
if self.config.mouse_capture
&& !self.replaying_url_click
&& self.overlay.is_none()
&& self.mode == ClientShellMode::Terminal
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
+122 -49
View File
@@ -240,35 +240,55 @@ fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility(
#[test]
fn delayed_link_fallback_does_not_replay_against_changed_geometry() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot()));
state.set_pane_surface(surface());
state.compose(106, 20).expect("pane frame");
let pane = state.hits.panes[0].clone();
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: pane.inner_rect.x + 2,
row: pane.inner_rect.y + 1,
modifiers: KeyModifiers::CONTROL,
};
let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
let request_id = match &activate.actions[..] {
[ClientShellAction::Endpoint { request, .. }] => request.id.clone(),
_ => panic!("expected link activation request"),
};
state.hits.panes[0].inner_rect.x = state.hits.panes[0].inner_rect.x.saturating_add(1);
let (_, actions) = state.handle_endpoint_result(
"boot-1",
&request_id,
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
url: None,
handled: false,
}),
);
assert!(actions.is_empty());
assert!(state.url_click_consumes_until_up);
for url in [
None,
Some("https://example.test"),
Some("file:///C:/Code/note.md"),
] {
for changed in 0..4 {
let mut state =
ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot()));
state.set_pane_surface(surface());
state.compose(106, 20).expect("pane frame");
let pane = state.hits.panes[0].clone();
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: pane.inner_rect.x + 2,
row: pane.inner_rect.y + 1,
modifiers: KeyModifiers::CONTROL,
};
let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
let [ClientShellAction::Endpoint { request, .. }] = &activate.actions[..] else {
panic!("expected link activation request");
};
match changed {
0 => state.hits.panes[0].inner_rect.x += 1,
1 => state.mode = ClientShellMode::Navigate,
2 => state.config.mouse_capture = false,
_ => {
state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
query: TextEditor::default(),
search_focused: false,
scroll: 0,
}))
}
}
let (_, actions) = state.handle_endpoint_result(
"boot-1",
&request.id,
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
url: url.map(str::to_owned),
handled: false,
}),
);
assert!(
actions.is_empty(),
"late replies must not replay or open links"
);
assert!(state.url_click_consumes_until_up);
}
}
}
#[test]
@@ -839,26 +859,79 @@ fn rename_pane_empty_value_is_preserved_as_a_clear_request() {
#[test]
fn styled_client_composition_preserves_pane_hyperlinks() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot()));
let mut pane_surface = surface();
let linked = Buffer::with_lines(["LIVE", "PANE"]);
pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks(
&linked,
None,
&[((0, 0), "L".into(), "https://example.test".into())],
);
state.set_pane_surface(pane_surface);
let mut selection =
crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1));
assert!(selection.finish());
state.selection = Some(selection);
let frame = state.compose(106, 20).expect("composed frame");
let hit = &state.hits.panes[0];
let index =
usize::from(hit.inner_rect.y) * usize::from(frame.width) + usize::from(hit.inner_rect.x);
let link = frame.cells[index].hyperlink.expect("linked cell") as usize;
assert_eq!(frame.hyperlinks[link], "https://example.test");
for uri in [
"https://example.test",
"obsidian://open?vault=meta&file=note.md",
] {
for draw_host_cursor in [false, true] {
let mut state =
ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.set_snapshot(Box::new(snapshot()));
let mut pane_surface = surface();
let linked = Buffer::with_lines(["LIVE", "PANE"]);
pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks(
&linked,
None,
&[((0, 0), "L".into(), uri.into())],
);
state.set_pane_surface(pane_surface);
let mut selection =
crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1));
assert!(selection.finish());
state.selection = Some(selection);
let frame = state.compose(106, 20).expect("composed frame");
let hit = &state.hits.panes[0];
let index = usize::from(hit.inner_rect.y) * usize::from(frame.width)
+ usize::from(hit.inner_rect.x);
let link = frame.cells[index].hyperlink.expect("linked cell") as usize;
assert_eq!(frame.hyperlinks[link], uri);
let x = hit.inner_rect.x;
let y = hit.inner_rect.y;
let mut client = crate::client::state::ClientState::test_new();
client.draw_host_cursor = draw_host_cursor;
let mut terminal = crate::ghostty::Terminal::new(106, 20, 0).unwrap();
for (phase, (capture, repaint)) in [
(false, false),
(true, false),
(false, false),
(true, true),
(true, false),
(false, true),
]
.into_iter()
.enumerate()
{
client.shell_mouse_capture_preference = capture;
// Effective mouse reporting may still be requested by a pane app.
client.mouse_capture_active = true;
client.repaint_pending = repaint;
let mut frame = state.compose(106, 20).expect("linked semantic frame");
let link = frame.cells[index].hyperlink.expect("retained link") as usize;
assert_eq!(frame.hyperlinks[link], uri);
// Change the linked cell on the diff pass so it must be painted again.
if phase == 4 {
frame.frame.cells[index].symbol = "D".into();
}
frame.frame.cursor = Some(crate::protocol::CursorState {
x: x + 1,
y,
visible: true,
shape: Default::default(),
});
let expected = frame.cells[index].symbol.clone();
let mut output = Vec::new();
assert!(client.try_present_frame_to(&mut output, frame));
terminal.write(&output);
assert_eq!(
terminal.viewport_hyperlink_uri(x, u32::from(y)).unwrap(),
(!capture).then_some(uri.to_owned()),
"host link ownership must follow configured capture for {uri}"
);
let (_, label) = terminal.screen_cell(x, u32::from(y)).unwrap();
assert_eq!(label, expected.chars().map(u32::from).collect::<Vec<_>>());
}
}
}
}
#[test]
+11
View File
@@ -276,6 +276,17 @@ fn ctrl_hover_ignores_late_reply_after_pointer_leaves() {
})]);
assert!(!resolve_hover(&mut state, &id));
assert!(state.link_hover.is_none());
let mouse = hover_mouse(&state, 1, 0);
let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]));
state.config.mouse_capture = false;
assert!(!resolve_hover(&mut state, &id));
let ignored = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]);
assert!(
ignored.actions.is_empty(),
"native links must not request Herdr hover"
);
assert!(state.link_hover.is_none());
}
#[test]
+71
View File
@@ -112,6 +112,77 @@ fn ctrl_click_routes_link_activation_through_endpoint_then_client_host() {
[ClientShellAction::OpenSafeWebUrl(url)] if url == "https://example.test"
));
assert!(!state.url_click_consumes_until_up);
let activate = |state: &mut ClientShellState, url: &str, handled| {
let click = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else {
panic!("expected link activation request");
};
state.handle_raw_events(vec![RawInputEvent::Mouse(up)]);
state
.handle_endpoint_result(
"boot-1",
&request.id,
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
url: Some(url.into()),
handled,
}),
)
.1
};
for url in [
"https://example.test",
"file:///C:/Code/note.md",
"obsidian://open?vault=meta",
] {
assert!(
activate(&mut state, url, true).is_empty(),
"claimed links belong to plugins"
);
if !url.starts_with("https:") {
assert!(
matches!(
&activate(&mut state, url, false)[..],
[ClientShellAction::ReplayMouse(_)]
),
"unclaimed non-web links must not open externally"
);
}
}
let click = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else {
panic!("expected link activation request");
};
let (_, actions) = state.handle_endpoint_result(
"boot-1",
&request.id,
Err(ClientShellEndpointError {
code: Some("plugin_link_failed".into()),
message: "Plugin could not start".into(),
}),
);
assert!(
actions.is_empty(),
"claimed-link errors must not fall through"
);
assert!(state.visible_endpoint_notice.is_some());
state.handle_raw_events(vec![RawInputEvent::Mouse(up)]);
// Pane apps can still request mouse reporting when Herdr capture is off.
state.config.mouse_capture = false;
let mut pane_surface = surface();
pane_surface.panes[0].mouse_reporting = true;
state.set_pane_surface(pane_surface);
state.compose(106, 20).expect("capture-off pane frame");
let native = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]);
assert!(!native.actions.iter().any(|action| matches!(action,
ClientShellAction::Endpoint { request, .. }
if matches!(request.method, crate::api::schema::Method::PaneLinkActivate(_)))));
assert!(matches!(&native.requests[..],
[ClientMessage::ClientShellPaneInput { events, .. }]
if matches!(&events[..], [ClientPaneInputEvent::Mouse { modifiers, .. }]
if *modifiers == KeyModifiers::CONTROL.bits())));
assert!(state.selection.is_none());
}
#[test]
+15 -3
View File
@@ -545,14 +545,27 @@ impl ClientState {
pub(super) fn try_present_frame(
&mut self,
frame_data: impl Into<frame_output::ComposedFrame>,
) -> bool {
self.try_present_frame_to(&mut io::stdout(), frame_data)
}
pub(super) fn try_present_frame_to(
&mut self,
writer: &mut impl io::Write,
frame_data: impl Into<frame_output::ComposedFrame>,
) -> bool {
if self.presentation_frozen {
return false;
}
let frame_output::ComposedFrame {
frame: frame_data,
frame: mut frame_data,
graphics,
} = frame_data.into();
// With capture enabled, host OSC 8 can intercept Ctrl-click before plugins.
// Keep semantic links internally and restore native links when capture is off.
if self.shell_mouse_capture_preference {
frame_data.hyperlinks.clear();
}
let frame_data = if self.draw_host_cursor {
render_ansi::frame_with_drawn_cursor(frame_data)
} else {
@@ -564,8 +577,7 @@ impl ClientState {
} else {
self.blit_encoder.encode(&frame_data, self.repaint_pending)
};
let mut stdout = io::stdout();
if let Err(error) = self.write_composed_output(&mut stdout, &encoded.bytes, graphics) {
if let Err(error) = self.write_composed_output(writer, &encoded.bytes, graphics) {
tracing::warn!(%error, "failed to present client frame");
self.repaint_pending = true;
return false;