mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 08:01:05 +00:00
feat: add configurable pane screen and scrollback clearing (#4383)
This commit is contained in:
@@ -5707,6 +5707,22 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"const": "pane.clear",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/schemas/request/$defs/PaneTarget"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -697,6 +697,12 @@
|
||||
"default": "\"prefix+e\"",
|
||||
"description": "Open the focused pane scrollback in $EDITOR."
|
||||
},
|
||||
{
|
||||
"key": "keys.clear_pane",
|
||||
"type": "keybinding",
|
||||
"default": "unset",
|
||||
"description": "Clear the focused pane screen and scrollback, keeping the current cursor line and its visible soft-wrapped rows. Sends no input to the running program and does nothing on the alternate screen (for example, Vim). Unset by default; for example, clear_pane = \"prefix+ctrl+k\"."
|
||||
},
|
||||
{
|
||||
"key": "keys.copy_mode",
|
||||
"type": "keybinding",
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
|
||||
| Method::PaneFocusDirection(_)
|
||||
| Method::PaneResize(_)
|
||||
| Method::PaneScroll(_)
|
||||
| Method::PaneClear(_)
|
||||
| Method::PaneEditScrollback(_)
|
||||
| Method::PaneFocus(_)
|
||||
| Method::PaneInputSet(_)
|
||||
|
||||
@@ -165,6 +165,8 @@ pub enum Method {
|
||||
PaneResize(PaneResizeParams),
|
||||
#[serde(rename = "pane.scroll")]
|
||||
PaneScroll(PaneScrollParams),
|
||||
#[serde(rename = "pane.clear")]
|
||||
PaneClear(PaneTarget),
|
||||
#[serde(rename = "pane.edit_scrollback")]
|
||||
PaneEditScrollback(PaneTarget),
|
||||
#[serde(rename = "pane.selection.read")]
|
||||
|
||||
@@ -457,6 +457,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str {
|
||||
Method::PaneFocusDirection(_) => "pane.focus_direction",
|
||||
Method::PaneResize(_) => "pane.resize",
|
||||
Method::PaneScroll(_) => "pane.scroll",
|
||||
Method::PaneClear(_) => "pane.clear",
|
||||
Method::PaneEditScrollback(_) => "pane.edit_scrollback",
|
||||
Method::PaneSelectionRead(_) => "pane.selection.read",
|
||||
Method::PaneCopyMotion(_) => "pane.copy_motion",
|
||||
|
||||
@@ -1101,6 +1101,7 @@ impl App {
|
||||
}
|
||||
Method::PaneResize(params) => return self.handle_pane_resize(request.id, params),
|
||||
Method::PaneScroll(params) => return self.handle_pane_scroll(request.id, params),
|
||||
Method::PaneClear(target) => return self.handle_pane_clear(request.id, target),
|
||||
Method::PaneEditScrollback(target) => {
|
||||
return self.handle_pane_edit_scrollback(request.id, target);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,22 @@ impl App {
|
||||
encode_success(id, ResponseResult::PaneInfo { pane })
|
||||
}
|
||||
|
||||
pub(super) fn handle_pane_clear(&mut self, id: String, target: PaneTarget) -> String {
|
||||
let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
|
||||
return pane_not_found(id, &target.pane_id);
|
||||
};
|
||||
let Some(runtime) =
|
||||
self.state
|
||||
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
|
||||
else {
|
||||
return pane_not_found(id, &target.pane_id);
|
||||
};
|
||||
match runtime.clear_screen() {
|
||||
Ok(()) => encode_success(id, ResponseResult::Ok {}),
|
||||
Err(err) => encode_error(id, "pane_clear_failed", err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_pane_scroll(&mut self, id: String, params: PaneScrollParams) -> String {
|
||||
let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else {
|
||||
return pane_not_found(id, ¶ms.pane_id);
|
||||
@@ -2349,6 +2365,26 @@ mod tests {
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_clear_pane_mutates_endpoint_owned_history() {
|
||||
let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime();
|
||||
let request = crate::api::schema::Request {
|
||||
id: "clear".into(),
|
||||
method: crate::api::schema::Method::PaneClear(PaneTarget {
|
||||
pane_id: public_pane_id,
|
||||
}),
|
||||
};
|
||||
assert!(crate::api::request_changes_ui(&request));
|
||||
let response = app.handle_api_request(request);
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(success.result, ResponseResult::Ok {});
|
||||
let runtime = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.scroll_metrics().unwrap().max_offset_from_bottom, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_pane_get_exposes_scroll_metrics() {
|
||||
let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime();
|
||||
|
||||
@@ -1064,6 +1064,9 @@ impl ClientShellState {
|
||||
pane_id: focused_pane,
|
||||
mode: PaneZoomMode::Toggle,
|
||||
})),
|
||||
KeybindAction::ClearPane => Some(Method::PaneClear(PaneTarget {
|
||||
pane_id: focused_pane?,
|
||||
})),
|
||||
KeybindAction::EditScrollback => Some(Method::PaneEditScrollback(PaneTarget {
|
||||
pane_id: focused_pane?,
|
||||
})),
|
||||
|
||||
@@ -884,6 +884,25 @@ fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() {
|
||||
assert!(state.chrome_drag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_pane_binding_targets_the_focused_endpoint_pane() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
let mut input = ClientShellInput::default();
|
||||
state.record_binding(
|
||||
crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClearPane),
|
||||
&mut input,
|
||||
);
|
||||
assert!(input.requests.is_empty());
|
||||
assert!(matches!(
|
||||
&input.actions[..],
|
||||
[ClientShellAction::Endpoint { request, .. }]
|
||||
if matches!(&request.method, crate::api::schema::Method::PaneClear(target)
|
||||
if target.pane_id == "pane_1")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_scrollback_binding_targets_the_focused_endpoint_pane() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
|
||||
@@ -347,6 +347,7 @@ pub struct Keybinds {
|
||||
pub close_tab: ActionKeybinds,
|
||||
pub rename_pane: ActionKeybinds,
|
||||
pub edit_scrollback: ActionKeybinds,
|
||||
pub clear_pane: ActionKeybinds,
|
||||
pub copy_mode: ActionKeybinds,
|
||||
pub focus_pane_left: ActionKeybinds,
|
||||
pub focus_pane_down: ActionKeybinds,
|
||||
@@ -515,6 +516,7 @@ impl Config {
|
||||
close_tab: empty_action!(),
|
||||
rename_pane: empty_action!(),
|
||||
edit_scrollback: empty_action!(),
|
||||
clear_pane: empty_action!(),
|
||||
copy_mode: empty_action!(),
|
||||
focus_pane_left: empty_action!(),
|
||||
focus_pane_down: empty_action!(),
|
||||
@@ -662,6 +664,7 @@ impl Config {
|
||||
apply_action!(keybinds.close_tab, close_tab, source);
|
||||
apply_action!(keybinds.rename_pane, rename_pane, source);
|
||||
apply_action!(keybinds.edit_scrollback, edit_scrollback, source);
|
||||
apply_action!(keybinds.clear_pane, clear_pane, source);
|
||||
apply_action!(keybinds.copy_mode, copy_mode, source);
|
||||
apply_action!(keybinds.focus_pane_left, focus_pane_left, source);
|
||||
apply_action!(keybinds.focus_pane_down, focus_pane_down, source);
|
||||
|
||||
@@ -405,6 +405,7 @@ pub struct KeysConfig {
|
||||
pub rename_pane: BindingConfig,
|
||||
/// Open the focused pane scrollback in $EDITOR. Default: "prefix+e".
|
||||
pub edit_scrollback: BindingConfig,
|
||||
pub clear_pane: BindingConfig,
|
||||
/// Enter keyboard copy mode for the focused pane. Default: "prefix+[".
|
||||
pub copy_mode: BindingConfig,
|
||||
/// Focus the pane to the left. Default: "prefix+h".
|
||||
@@ -536,6 +537,7 @@ pub(crate) struct KeysConfigOverlay {
|
||||
rename_pane: Option<BindingConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
edit_scrollback: Option<BindingConfig>,
|
||||
clear_pane: Option<BindingConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
copy_mode: Option<BindingConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -646,6 +648,7 @@ impl<'de> Deserialize<'de> for KeysConfig {
|
||||
apply_field!(close_tab);
|
||||
apply_field!(rename_pane);
|
||||
apply_field!(edit_scrollback);
|
||||
apply_field!(clear_pane);
|
||||
apply_field!(copy_mode);
|
||||
apply_field!(focus_pane_left);
|
||||
apply_field!(focus_pane_down);
|
||||
@@ -750,6 +753,7 @@ impl KeysConfig {
|
||||
copy_effective_action_field!(close_tab, keybinds.close_tab);
|
||||
copy_effective_action_field!(rename_pane, keybinds.rename_pane);
|
||||
copy_effective_action_field!(edit_scrollback, keybinds.edit_scrollback);
|
||||
copy_effective_action_field!(clear_pane, keybinds.clear_pane);
|
||||
copy_effective_action_field!(copy_mode, keybinds.copy_mode);
|
||||
copy_effective_action_field!(focus_pane_left, keybinds.focus_pane_left);
|
||||
copy_effective_action_field!(focus_pane_down, keybinds.focus_pane_down);
|
||||
@@ -1118,6 +1122,7 @@ impl Default for KeysConfig {
|
||||
close_tab: BindingConfig::one("prefix+shift+x"),
|
||||
rename_pane: BindingConfig::one("prefix+shift+p"),
|
||||
edit_scrollback: BindingConfig::one("prefix+e"),
|
||||
clear_pane: BindingConfig::default(),
|
||||
copy_mode: BindingConfig::one("prefix+["),
|
||||
focus_pane_left: BindingConfig::one("prefix+h"),
|
||||
focus_pane_down: BindingConfig::one("prefix+j"),
|
||||
|
||||
@@ -3236,6 +3236,10 @@ unsafe extern "C" {
|
||||
#[doc = " Perform a full reset of the terminal (RIS).\n\n Resets all terminal state back to its initial configuration, including\n modes, scrollback, scrolling region, and screen contents. The terminal\n dimensions are preserved.\n\n @param terminal The terminal handle (may be NULL, in which case this is a no-op)\n\n @ingroup terminal"]
|
||||
pub fn ghostty_terminal_reset(terminal: GhosttyTerminal);
|
||||
}
|
||||
unsafe extern "C" {
|
||||
#[doc = " Clear screen and history, retaining the cursor's soft-wrapped active line.\n Does not alter the VT parser or write to the child process. Returns false\n without changing the terminal on the alternate screen or for a NULL handle.\n Otherwise returns true and moves the retained line to the top of the screen."]
|
||||
pub fn ghostty_terminal_clear_screen(terminal: GhosttyTerminal) -> bool;
|
||||
}
|
||||
unsafe extern "C" {
|
||||
#[doc = " Resize the terminal to the given dimensions.\n\n Changes the number of columns and rows in the terminal. The primary\n screen will reflow content if wraparound mode is enabled; the alternate\n screen does not reflow. If the dimensions are unchanged, this is a no-op.\n\n This also updates the terminal's pixel dimensions (used for image\n protocols and size reports), disables synchronized output mode (allowed\n by the spec so that resize results are shown immediately), and sends an\n in-band size report if mode 2048 is enabled.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param cols New width in cells (must be greater than zero)\n @param rows New height in cells (must be greater than zero)\n @param cell_width_px Width of a single cell in pixels\n @param cell_height_px Height of a single cell in pixels\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup terminal"]
|
||||
pub fn ghostty_terminal_resize(
|
||||
|
||||
@@ -1619,6 +1619,10 @@ impl Terminal {
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn clear_screen(&mut self) -> bool {
|
||||
unsafe { ffi::ghostty_terminal_clear_screen(self.raw) }
|
||||
}
|
||||
|
||||
pub fn scroll_viewport_bottom(&mut self) {
|
||||
let viewport = ffi::GhosttyTerminalScrollViewport {
|
||||
tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_BOTTOM,
|
||||
|
||||
@@ -160,6 +160,7 @@ pub(crate) fn keybind_help_groups(
|
||||
entry(binding_label(&keybinds.close_pane), "close pane"),
|
||||
entry(binding_label(&keybinds.rename_pane), "rename pane"),
|
||||
entry(binding_label(&keybinds.edit_scrollback), "edit scrollback"),
|
||||
entry(binding_label(&keybinds.clear_pane), "clear pane"),
|
||||
entry(binding_label(&keybinds.copy_mode), "copy mode"),
|
||||
entry(binding_label(&keybinds.zoom), "zoom pane"),
|
||||
entry(binding_label(&keybinds.resize_mode), "resize mode"),
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(crate) enum KeybindAction {
|
||||
SplitHorizontal,
|
||||
ClosePane,
|
||||
EditScrollback,
|
||||
ClearPane,
|
||||
CopyMode,
|
||||
Zoom,
|
||||
EnterResizeMode,
|
||||
@@ -120,6 +121,7 @@ pub(crate) fn resolve_non_indexed_action(
|
||||
(&keybinds.close_tab, KeybindAction::CloseTab),
|
||||
(&keybinds.rename_pane, KeybindAction::RenamePane),
|
||||
(&keybinds.edit_scrollback, KeybindAction::EditScrollback),
|
||||
(&keybinds.clear_pane, KeybindAction::ClearPane),
|
||||
(&keybinds.copy_mode, KeybindAction::CopyMode),
|
||||
(&keybinds.focus_pane_left, KeybindAction::FocusPaneLeft),
|
||||
(&keybinds.focus_pane_down, KeybindAction::FocusPaneDown),
|
||||
@@ -258,6 +260,40 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_pane_is_unbound_by_default_and_configurable() {
|
||||
assert!(crate::config::Config::default()
|
||||
.keybinds()
|
||||
.clear_pane
|
||||
.bindings
|
||||
.is_empty());
|
||||
let config: crate::config::Config =
|
||||
toml::from_str("[keys]\nclear_pane = [\"super+k\", \"prefix+ctrl+k\"]").unwrap();
|
||||
assert!(config.collect_diagnostics().is_empty());
|
||||
let keybinds = config.keybinds();
|
||||
assert!(matches!(
|
||||
resolve_direct_binding(
|
||||
&keybinds,
|
||||
&TerminalKey::new(KeyCode::Char('k'), KeyModifiers::SUPER)
|
||||
),
|
||||
Some(KeybindMatch::Action(KeybindAction::ClearPane))
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_prefix_binding(
|
||||
&keybinds,
|
||||
&TerminalKey::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
|
||||
),
|
||||
Some(KeybindMatch::Action(KeybindAction::ClearPane))
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_prefix_binding(
|
||||
&keybinds,
|
||||
&TerminalKey::new(KeyCode::Char('k'), KeyModifiers::SHIFT)
|
||||
),
|
||||
Some(KeybindMatch::Action(KeybindAction::SwapPaneUp))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shared_resolver_handles_direct_prefix_and_indexed_bindings() {
|
||||
let keybinds = Keybinds {
|
||||
|
||||
@@ -172,6 +172,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
||||
# close_tab = "prefix+shift+x"
|
||||
# rename_pane = "prefix+shift+p"
|
||||
# edit_scrollback = "prefix+e"
|
||||
# clear_pane = "" # unbound; e.g. "prefix+ctrl+k"
|
||||
# focus_pane_left = "prefix+h"
|
||||
# focus_pane_down = "prefix+j"
|
||||
# focus_pane_up = "prefix+k"
|
||||
|
||||
+64
@@ -3081,6 +3081,20 @@ impl PaneRuntime {
|
||||
self.compression.wake();
|
||||
}
|
||||
|
||||
pub fn clear_screen(&self) -> Result<(), String> {
|
||||
let guard = match self.content_write_lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
self.content_seq.fetch_add(1, Ordering::AcqRel);
|
||||
let result = self.terminal.clear_screen();
|
||||
self.content_seq.fetch_add(1, Ordering::Release);
|
||||
drop(guard);
|
||||
self.compression.wake();
|
||||
mark_detection_content_changed(&self.detection_content_seq);
|
||||
result
|
||||
}
|
||||
|
||||
/// Reset scroll to live view (offset = 0).
|
||||
pub fn scroll_reset(&self) {
|
||||
self.terminal.scroll_reset();
|
||||
@@ -3668,6 +3682,56 @@ impl PaneRuntime {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_pane_preserves_wrapped_input_and_unfinished_vt_sequence() {
|
||||
let (runtime, mut rx) = PaneRuntime::test_with_channel_and_scrollback_bytes(
|
||||
10,
|
||||
5,
|
||||
100_000,
|
||||
b"old\r\nold\r\nold\r\nold\r\nold\r\n\x1b[32m$ abcdefghijklmnop\x1b[1A\x1b[4G\x1b[",
|
||||
4,
|
||||
);
|
||||
let before = runtime.content_seq();
|
||||
runtime.scroll_up(1);
|
||||
runtime.clear_screen().unwrap();
|
||||
let snapshot = runtime.collect_dirty_patch_snapshot(10, 5).unwrap();
|
||||
assert!(snapshot.content_revision > before);
|
||||
assert!(!matches!(snapshot.patch, TerminalDirtyPatchOutcome::Clean));
|
||||
let metrics = runtime.scroll_metrics().unwrap();
|
||||
assert_eq!(metrics.max_offset_from_bottom, 0);
|
||||
assert_eq!(metrics.offset_from_bottom, 0);
|
||||
let text = runtime.recent_unwrapped_text_snapshot(100).text;
|
||||
assert!(text.contains("$ abcdefghijklmnop"), "{text:?}");
|
||||
assert!(!text.contains("old"), "{text:?}");
|
||||
runtime.test_process_pty_bytes(b"5 q");
|
||||
assert!(!runtime.visible_text().contains("5 q"));
|
||||
assert!(rx.try_recv().is_err(), "clear must not send child input");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_pane_preserves_alternate_screen_and_primary_history() {
|
||||
let runtime = PaneRuntime::test_with_scrollback_bytes(
|
||||
20,
|
||||
4,
|
||||
100_000,
|
||||
b"one\r\ntwo\r\nthree\r\nfour\r\nfive\x1b[?1049halt app",
|
||||
);
|
||||
let before = runtime.visible_text();
|
||||
runtime.clear_screen().unwrap();
|
||||
assert_eq!(runtime.visible_text(), before);
|
||||
runtime.test_process_pty_bytes(b"\x1b[?1049l");
|
||||
assert!(runtime
|
||||
.recent_unwrapped_text_snapshot(100)
|
||||
.text
|
||||
.contains("one"));
|
||||
runtime.clear_screen().unwrap();
|
||||
assert!(!runtime
|
||||
.recent_unwrapped_text_snapshot(100)
|
||||
.text
|
||||
.contains("one"));
|
||||
assert!(runtime.visible_text().contains("five"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dirty_patch_snapshot_keeps_clean_metadata_and_terminal_fallback() {
|
||||
let (runtime, _rx) = PaneRuntime::test_with_channel(20, 4);
|
||||
|
||||
@@ -258,6 +258,10 @@ impl PaneTerminal {
|
||||
self.ghostty.scroll_reset();
|
||||
}
|
||||
|
||||
pub fn clear_screen(&self) -> Result<(), String> {
|
||||
self.ghostty.clear_screen()
|
||||
}
|
||||
|
||||
pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
|
||||
self.ghostty.set_scroll_offset_from_bottom(lines);
|
||||
}
|
||||
@@ -1773,6 +1777,21 @@ impl GhosttyPaneTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_screen(&self) -> Result<(), String> {
|
||||
let mut core = self
|
||||
.core
|
||||
.lock()
|
||||
.map_err(|_| "terminal lock poisoned".to_owned())?;
|
||||
if core.terminal.clear_screen() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
core.recent_fallback = windows_recent_fallback::Cache::default();
|
||||
windows_recent_fallback::update(&mut core);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
|
||||
if let Ok(mut core) = self.core.lock() {
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -18,6 +18,7 @@ const CLIENT_SHELL_METHODS: &[&str] = &[
|
||||
"integration.install",
|
||||
"integration.list",
|
||||
"layout.set_split_ratio",
|
||||
"pane.clear",
|
||||
"pane.close",
|
||||
"pane.copy_motion",
|
||||
"pane.copy_search",
|
||||
@@ -287,7 +288,11 @@ mod tests {
|
||||
)))
|
||||
.expect("endpoint method shape fixture");
|
||||
let mut actual = endpoint_method_shape_digests();
|
||||
// Freeze the additive method separately without rewriting the published fixture.
|
||||
// Freeze additive methods separately without rewriting the published fixture.
|
||||
assert_eq!(
|
||||
actual.remove("pane.clear").as_deref(),
|
||||
Some("0301d288ba198ddaa427dd7421c71911cccaf4ea03544531efa8b67ca21b08f6")
|
||||
);
|
||||
assert_eq!(
|
||||
actual.remove("pane.link.resolve").as_deref(),
|
||||
Some("f5e4a3e01453ae7b188f127ce951c12c20e0bebcc17cc364eeb6d1a01fd5bf81")
|
||||
|
||||
@@ -262,6 +262,7 @@ impl HeadlessServer {
|
||||
| Method::PaneRename(_)
|
||||
| Method::PaneResize(_)
|
||||
| Method::PaneScroll(_)
|
||||
| Method::PaneClear(_)
|
||||
| Method::PaneSplit(_)
|
||||
| Method::PaneSwap(_)
|
||||
| Method::PaneZoom(_)
|
||||
|
||||
@@ -270,6 +270,10 @@ impl TerminalRuntime {
|
||||
self.0.scroll_reset();
|
||||
}
|
||||
|
||||
pub fn clear_screen(&self) -> Result<(), String> {
|
||||
self.0.clear_screen()
|
||||
}
|
||||
|
||||
pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
|
||||
self.0.set_scroll_offset_from_bottom(lines);
|
||||
}
|
||||
|
||||
Vendored
+38
@@ -132,3 +132,41 @@ just test-one link_activation
|
||||
just test-one ctrl_click
|
||||
just check
|
||||
```
|
||||
|
||||
## 0006 clear screen while preserving the cursor line
|
||||
|
||||
status: active
|
||||
|
||||
patch: `vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch`
|
||||
|
||||
herdr issue: none; requested in https://github.com/herdrdev/herdr/discussions/545
|
||||
|
||||
upstream discussion: not opened
|
||||
|
||||
upstream pr: not opened
|
||||
|
||||
vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51`
|
||||
|
||||
local files:
|
||||
|
||||
- `vendor/libghostty-vt/include/ghostty/vt/terminal.h`
|
||||
- `vendor/libghostty-vt/src/lib_vt.zig`
|
||||
- `vendor/libghostty-vt/src/terminal/c/main.zig`
|
||||
- `vendor/libghostty-vt/src/terminal/c/terminal.zig`
|
||||
|
||||
reason: Herdr needs an explicit screen/history clear that preserves the cursor's
|
||||
visible soft-wrapped line without writing to the child or interrupting a partial
|
||||
VT sequence. The new C function operates directly on the screen, leaves alternate
|
||||
screens untouched, clears image placements, and marks the result dirty.
|
||||
|
||||
remove when: the vendored C API provides an equivalent parser-independent clear
|
||||
operation preserving the visible cursor line, and Herdr passes the checks below
|
||||
using it without this patch.
|
||||
|
||||
verification:
|
||||
|
||||
```sh
|
||||
just test-one clear_pane
|
||||
just maintenance-test
|
||||
just check
|
||||
```
|
||||
|
||||
@@ -2007,6 +2007,14 @@ GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal);
|
||||
*/
|
||||
GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal);
|
||||
|
||||
/**
|
||||
* Clear screen and history, retaining the cursor's soft-wrapped active line.
|
||||
* Does not alter the VT parser or write to the child process. Returns false
|
||||
* without changing the terminal on the alternate screen or for a NULL handle.
|
||||
* Otherwise returns true and moves the retained line to the top of the screen.
|
||||
*/
|
||||
GHOSTTY_API bool ghostty_terminal_clear_screen(GhosttyTerminal terminal);
|
||||
|
||||
/**
|
||||
* Resize the terminal to the given dimensions.
|
||||
*
|
||||
|
||||
Vendored
+1
@@ -317,6 +317,7 @@ comptime {
|
||||
@export(&c.terminal_new, .{ .name = "ghostty_terminal_new" });
|
||||
@export(&c.terminal_free, .{ .name = "ghostty_terminal_free" });
|
||||
@export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" });
|
||||
@export(&c.terminal_clear_screen, .{ .name = "ghostty_terminal_clear_screen" });
|
||||
@export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" });
|
||||
@export(&c.terminal_set, .{ .name = "ghostty_terminal_set" });
|
||||
@export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" });
|
||||
|
||||
@@ -183,6 +183,7 @@ pub const sys_set = sys.set;
|
||||
pub const terminal_new = terminal.new;
|
||||
pub const terminal_free = terminal.free;
|
||||
pub const terminal_reset = terminal.reset;
|
||||
pub const terminal_clear_screen = terminal.clearScreen;
|
||||
pub const terminal_resize = terminal.resize;
|
||||
pub const terminal_set = terminal.set;
|
||||
pub const terminal_vt_write = terminal.vt_write;
|
||||
|
||||
@@ -1504,6 +1504,40 @@ pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void {
|
||||
t.fullReset();
|
||||
}
|
||||
|
||||
/// Clear output without feeding synthetic bytes into the child's VT stream.
|
||||
pub fn clearScreen(terminal_: Terminal) callconv(lib.calling_conv) bool {
|
||||
const t: *ZigTerminal = (terminal_ orelse return false).terminal;
|
||||
if (t.screens.active_key == .alternate) return false;
|
||||
const screen = t.screens.active;
|
||||
|
||||
var first = screen.cursor.y;
|
||||
while (first > 0) {
|
||||
const pin = screen.pages.pin(.{ .active = .{ .y = first } }).?;
|
||||
if (!pin.rowAndCell().row.wrap_continuation) break;
|
||||
first -= 1;
|
||||
}
|
||||
var last = screen.cursor.y;
|
||||
while (last + 1 < t.rows) {
|
||||
const pin = screen.pages.pin(.{ .active = .{ .y = last } }).?;
|
||||
if (!pin.rowAndCell().row.wrap) break;
|
||||
last += 1;
|
||||
}
|
||||
|
||||
screen.clearSelection();
|
||||
if (last + 1 < t.rows) {
|
||||
screen.clearRows(.{ .active = .{ .y = last + 1 } }, null, false);
|
||||
}
|
||||
screen.eraseHistory(null);
|
||||
if (first > 0) screen.eraseActive(first - 1);
|
||||
screen.pages.pin(.{ .active = .{} }).?.rowAndCell().row.wrap_continuation = false;
|
||||
screen.scroll(.active);
|
||||
if (comptime build_options.kitty_graphics) {
|
||||
screen.kitty_images.delete(t.io(), screen.alloc, t, .{ .all = true });
|
||||
}
|
||||
t.flags.dirty.clear = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// C: GhosttyKittyGraphics
|
||||
pub const KittyGraphics = kitty_gfx_c.KittyGraphics;
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h
|
||||
index ad5eec54..81f9ebd0 100644
|
||||
--- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h
|
||||
+++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h
|
||||
@@ -2007,6 +2007,14 @@ GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal);
|
||||
*/
|
||||
GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal);
|
||||
|
||||
+/**
|
||||
+ * Clear screen and history, retaining the cursor's soft-wrapped active line.
|
||||
+ * Does not alter the VT parser or write to the child process. Returns false
|
||||
+ * without changing the terminal on the alternate screen or for a NULL handle.
|
||||
+ * Otherwise returns true and moves the retained line to the top of the screen.
|
||||
+ */
|
||||
+GHOSTTY_API bool ghostty_terminal_clear_screen(GhosttyTerminal terminal);
|
||||
+
|
||||
/**
|
||||
* Resize the terminal to the given dimensions.
|
||||
*
|
||||
diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig
|
||||
index 48536568..05bc0baa 100644
|
||||
--- a/vendor/libghostty-vt/src/lib_vt.zig
|
||||
+++ b/vendor/libghostty-vt/src/lib_vt.zig
|
||||
@@ -317,6 +317,7 @@ comptime {
|
||||
@export(&c.terminal_new, .{ .name = "ghostty_terminal_new" });
|
||||
@export(&c.terminal_free, .{ .name = "ghostty_terminal_free" });
|
||||
@export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" });
|
||||
+ @export(&c.terminal_clear_screen, .{ .name = "ghostty_terminal_clear_screen" });
|
||||
@export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" });
|
||||
@export(&c.terminal_set, .{ .name = "ghostty_terminal_set" });
|
||||
@export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" });
|
||||
diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig
|
||||
index 0ba1224a..5ac299ab 100644
|
||||
--- a/vendor/libghostty-vt/src/terminal/c/main.zig
|
||||
+++ b/vendor/libghostty-vt/src/terminal/c/main.zig
|
||||
@@ -183,6 +183,7 @@ pub const sys_set = sys.set;
|
||||
pub const terminal_new = terminal.new;
|
||||
pub const terminal_free = terminal.free;
|
||||
pub const terminal_reset = terminal.reset;
|
||||
+pub const terminal_clear_screen = terminal.clearScreen;
|
||||
pub const terminal_resize = terminal.resize;
|
||||
pub const terminal_set = terminal.set;
|
||||
pub const terminal_vt_write = terminal.vt_write;
|
||||
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
|
||||
index 73097573..91bbe310 100644
|
||||
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
|
||||
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
|
||||
@@ -1504,6 +1504,40 @@ pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void {
|
||||
t.fullReset();
|
||||
}
|
||||
|
||||
+/// Clear output without feeding synthetic bytes into the child's VT stream.
|
||||
+pub fn clearScreen(terminal_: Terminal) callconv(lib.calling_conv) bool {
|
||||
+ const t: *ZigTerminal = (terminal_ orelse return false).terminal;
|
||||
+ if (t.screens.active_key == .alternate) return false;
|
||||
+ const screen = t.screens.active;
|
||||
+
|
||||
+ var first = screen.cursor.y;
|
||||
+ while (first > 0) {
|
||||
+ const pin = screen.pages.pin(.{ .active = .{ .y = first } }).?;
|
||||
+ if (!pin.rowAndCell().row.wrap_continuation) break;
|
||||
+ first -= 1;
|
||||
+ }
|
||||
+ var last = screen.cursor.y;
|
||||
+ while (last + 1 < t.rows) {
|
||||
+ const pin = screen.pages.pin(.{ .active = .{ .y = last } }).?;
|
||||
+ if (!pin.rowAndCell().row.wrap) break;
|
||||
+ last += 1;
|
||||
+ }
|
||||
+
|
||||
+ screen.clearSelection();
|
||||
+ if (last + 1 < t.rows) {
|
||||
+ screen.clearRows(.{ .active = .{ .y = last + 1 } }, null, false);
|
||||
+ }
|
||||
+ screen.eraseHistory(null);
|
||||
+ if (first > 0) screen.eraseActive(first - 1);
|
||||
+ screen.pages.pin(.{ .active = .{} }).?.rowAndCell().row.wrap_continuation = false;
|
||||
+ screen.scroll(.active);
|
||||
+ if (comptime build_options.kitty_graphics) {
|
||||
+ screen.kitty_images.delete(t.io(), screen.alloc, t, .{ .all = true });
|
||||
+ }
|
||||
+ t.flags.dirty.clear = true;
|
||||
+ return true;
|
||||
+}
|
||||
+
|
||||
/// C: GhosttyKittyGraphics
|
||||
pub const KittyGraphics = kitty_gfx_c.KittyGraphics;
|
||||
|
||||
Reference in New Issue
Block a user