feature:1、新增Phyger主题;2、支持terminal搜索 (#52)

This commit is contained in:
phygerr
2026-06-23 10:43:26 +08:00
committed by GitHub
parent 5ae44c9e44
commit 3b7165a4bd
14 changed files with 1366 additions and 41 deletions
+308
View File
@@ -0,0 +1,308 @@
{
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
"name": "Phyger",
"author": "phygerr",
"url": "https://github.com/phygerr",
"themes": [
{
"name": "Phyger Light",
"mode": "light",
"colors": {
"accent.background": "#D6E4F0",
"accent.foreground": "#1B3A5C",
"background": "#F8F9FC",
"border": "#D0D5DD",
"danger.background": "#D94848",
"danger.active.background": "#B53535",
"danger.foreground": "#FFFFFF",
"foreground": "#2B2D30",
"input.border": "#C0C7D0",
"link.active.foreground": "#2563EB",
"link.foreground": "#2563EB",
"link.hover.foreground": "#1D4ED8",
"list.active.background": "#2563EB18",
"list.hover.background": "#E8EDF4",
"list.active.border": "#2563EB",
"list.even.background": "#F0F2F7",
"muted.background": "#E2E6EE88",
"muted.foreground": "#7A7F88",
"panel.background": "#ECEEF3",
"popover.background": "#F8F9FC",
"popover.foreground": "#2B2D30",
"primary.active.background": "#1D4ED8",
"primary.background": "#2563EB",
"primary.foreground": "#FFFFFF",
"primary.hover.background": "#3B72F0",
"ring": "#2563EB",
"scrollbar.background": "#F8F9FC00",
"scrollbar.thumb.background": "#C0C7D0",
"secondary.background": "#E8EDF4",
"secondary.active.background": "#D0D5DD",
"secondary.foreground": "#2B2D30",
"secondary.hover.background": "#DCE2EB",
"tab.active.background": "#F8F9FC",
"tab.active.foreground": "#2B2D30",
"tab.background": "#ECEEF300",
"tab.foreground": "#7A7F88",
"tab_bar.background": "#ECEEF3",
"title_bar.background": "#ECEEF3",
"title_bar.border": "#D0D5DD",
"base.magenta.light": "#C2185B44",
"base.red": "#D32F2F",
"base.green": "#2E7D32",
"base.yellow": "#BF8C00",
"base.blue": "#1565C0",
"base.magenta": "#8E24AA",
"base.cyan": "#00838F"
},
"highlight": {
"editor.foreground": "#2B2D30",
"editor.background": "#F8F9FC",
"editor.active_line.background": "#E8EDF4",
"editor.line_number": "#A0A5AE",
"editor.active_line_number": "#2B2D30",
"editor.invisible": "#A0A5AE55",
"conflict": "#D32F2F",
"created": "#2E7D32",
"deleted": "#C62828",
"error": "#D32F2F",
"hidden": "#A0A5AE",
"hint": "#1565C0",
"ignored": "#A0A5AE",
"info": "#00838F",
"modified": "#BF8C00",
"predictive": "#A0A5AE",
"renamed": "#BF8C00",
"success": "#2E7D32",
"unreachable": "#A0A5AE",
"warning": "#E65100",
"syntax": {
"attribute": {
"color": "#7B1FA2"
},
"boolean": {
"color": "#00838F"
},
"comment": {
"color": "#6A737D",
"font_style": "italic"
},
"comment.doc": {
"color": "#6A737D",
"font_style": "italic"
},
"constant": {
"color": "#00838F"
},
"constructor": {
"color": "#1565C0"
},
"embedded": {
"color": "#2B2D30"
},
"function": {
"color": "#6A1B9A"
},
"keyword": {
"color": "#1565C0"
},
"link_text": {
"color": "#1565C0",
"font_style": "normal"
},
"link_uri": {
"color": "#00838F",
"font_style": "italic"
},
"number": {
"color": "#1B5E20"
},
"string": {
"color": "#BF360C"
},
"string.escape": {
"color": "#BF360C"
},
"string.regex": {
"color": "#BF360C"
},
"string.special": {
"color": "#00838F"
},
"string.special.symbol": {
"color": "#00838F"
},
"tag": {
"color": "#1565C0"
},
"text.literal": {
"color": "#00838F"
},
"title": {
"color": "#6A1B9A",
"font_weight": 600
},
"type": {
"color": "#2E7D32"
},
"property": {
"color": "#2B2D30"
},
"variable.special": {
"color": "#D32F2F"
}
}
}
},
{
"name": "Phyger Dark",
"mode": "dark",
"colors": {
"accent.background": "#2A3040",
"accent.foreground": "#C8D3E0",
"background": "#1A1D23",
"border": "#2E3340",
"danger.background": "#E05555",
"danger.active.background": "#CC3D3D",
"danger.foreground": "#FFFFFF",
"foreground": "#C8CED8",
"input.border": "#3A4050",
"link.active.foreground": "#6CB4EE",
"link.foreground": "#6CB4EE",
"link.hover.foreground": "#8FC8F6",
"list.active.background": "#6CB4EE15",
"list.hover.background": "#252A34",
"list.active.border": "#6CB4EE",
"list.even.background": "#1E2229",
"muted.background": "#252A3466",
"muted.foreground": "#6B7280",
"panel.background": "#1E2229",
"popover.background": "#1A1D23",
"popover.foreground": "#C8CED8",
"primary.active.background": "#4A90D9",
"primary.background": "#5BA0E0",
"primary.foreground": "#1A1D23",
"primary.hover.background": "#6CB4EE",
"ring": "#5BA0E0",
"scrollbar.background": "#1A1D2300",
"scrollbar.thumb.background": "#3A4050",
"secondary.active.background": "#333A48",
"secondary.background": "#252A34",
"secondary.foreground": "#C8CED8",
"secondary.hover.background": "#2E3542",
"tab.active.background": "#1A1D23",
"tab.active.foreground": "#C8CED8",
"tab.background": "#252A3400",
"tab.foreground": "#6B7280",
"tab_bar.background": "#1E2229DD",
"title_bar.background": "#1E2229",
"title_bar.border": "#2E3340",
"base.magenta.light": "#C792EA44",
"base.red": "#E06060",
"base.green": "#7EC699",
"base.yellow": "#E8C97A",
"base.blue": "#6CB4EE",
"base.magenta": "#C792EA",
"base.cyan": "#56D4C7"
},
"highlight": {
"editor.foreground": "#C8CED8",
"editor.background": "#1A1D23",
"editor.active_line.background": "#222730",
"editor.line_number": "#5A6170",
"editor.active_line_number": "#C8CED8",
"editor.invisible": "#5A617055",
"conflict": "#E06060",
"created": "#7EC699",
"deleted": "#CC4444",
"error": "#E06060",
"hidden": "#6B7280",
"hint": "#C792EA",
"ignored": "#5A6170",
"info": "#56D4C7",
"modified": "#E8C97A",
"predictive": "#4A5568",
"renamed": "#E8C97A",
"success": "#7EC699",
"unreachable": "#5A6170",
"warning": "#E8C97A",
"syntax": {
"attribute": {
"color": "#C792EA"
},
"boolean": {
"color": "#56D4C7"
},
"comment": {
"color": "#5A6A7A",
"font_style": "italic"
},
"comment.doc": {
"color": "#5A6A7A",
"font_style": "italic"
},
"constant": {
"color": "#56D4C7"
},
"constructor": {
"color": "#6CB4EE"
},
"embedded": {
"color": "#C8CED8"
},
"function": {
"color": "#E2C08D"
},
"keyword": {
"color": "#6CB4EE"
},
"link_text": {
"color": "#6CB4EE",
"font_style": "normal"
},
"link_uri": {
"color": "#56D4C7",
"font_style": "italic"
},
"number": {
"color": "#7EC699"
},
"string": {
"color": "#E8A87C"
},
"string.escape": {
"color": "#E8A87C"
},
"string.regex": {
"color": "#E8A87C"
},
"string.special": {
"color": "#56D4C7"
},
"string.special.symbol": {
"color": "#56D4C7"
},
"tag": {
"color": "#6CB4EE"
},
"text.literal": {
"color": "#56D4C7"
},
"title": {
"color": "#E2C08D",
"font_weight": 600
},
"type": {
"color": "#7EC699"
},
"property": {
"color": "#C8CED8"
},
"variable.special": {
"color": "#E06060"
}
}
}
}
]
}
+3
View File
@@ -142,6 +142,9 @@ files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
settings_open_search: "Search"
search: "Search"
no_results: "No results"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_focus_pane_left: "Focus Pane Left"
+3
View File
@@ -144,6 +144,9 @@ files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
settings_open_search: "搜索"
search: "搜索"
no_results: "无结果"
settings_toggle_sidebar: "缩放侧边栏"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
settings_focus_pane_left: "向左切换焦点"
+9 -1
View File
@@ -18,6 +18,7 @@ gpui::actions!(
OpenSession,
OpenTransfers,
NewSsh,
OpenSearch,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
@@ -62,6 +63,11 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
label_key: "settings_new_ssh",
default_suffix: "n",
},
WorkspaceAction {
id: "OpenSearch",
label_key: "settings_open_search",
default_suffix: "f",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
@@ -206,6 +212,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
unbind_action!("OpenSession", crate::OpenSession);
unbind_action!("OpenTransfers", crate::OpenTransfers);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("OpenSearch", crate::OpenSearch);
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -261,6 +268,7 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("OpenSession", crate::OpenSession);
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("OpenSearch", crate::OpenSearch);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -281,7 +289,7 @@ impl KeybindingsPage {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch"],
),
(
"settings_group_keybind_zoom",
+33
View File
@@ -2,6 +2,7 @@ pub mod config_sync;
pub mod constants;
pub mod dialogs;
pub mod keybinding_recorder;
pub mod search;
pub mod startup;
pub mod theme;
pub mod ui;
@@ -288,6 +289,14 @@ pub(crate) struct Ashell {
pub(crate) last_system_sample: Instant,
pub(crate) last_theme_sync: Instant,
pub(crate) search_input: Entity<InputState>,
pub(crate) search_active: bool,
pub(crate) search_query: String,
pub(crate) search_matches: Vec<(i32, i32)>,
pub(crate) search_current: usize,
pub(crate) search_target_tab: Option<String>,
pub(crate) search_bar_bounds: Option<Bounds<Pixels>>,
pub(crate) system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
@@ -368,6 +377,9 @@ impl Ashell {
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
let sftp_new_folder_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
let search_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("search").to_string())
});
let config = ConfigStore::load().unwrap_or_else(|err| {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
@@ -437,6 +449,7 @@ impl Ashell {
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
cx.subscribe_in(&search_input, window, Self::on_input_event),
cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event),
cx.subscribe_in(&sync_username_input, window, Self::on_input_event),
cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event),
@@ -593,6 +606,14 @@ impl Ashell {
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
search_input,
search_active: false,
search_query: String::new(),
search_matches: Vec::new(),
search_current: 0,
search_target_tab: None,
search_bar_bounds: None,
system_tab_id: None,
sftp_handles: std::collections::HashMap::new(),
@@ -651,6 +672,18 @@ impl Ashell {
}
_ => {}
}
} else if input == &self.search_input {
if let InputEvent::PressEnter { .. } = event {
if self.search_query.is_empty()
|| self.search_input.read(cx).text().to_string() != self.search_query
{
self.perform_search(window, cx);
} else {
self.search_goto_next(cx);
}
window.prevent_default();
cx.stop_propagation();
}
}
cx.notify();
}
+486
View File
@@ -0,0 +1,486 @@
use std::collections::HashMap;
use gpui::{
Context, Focusable as _, Hsla, IntoElement, InteractiveElement as _, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex, input::Input,
};
use rust_i18n::t;
use crate::Ashell;
impl Ashell {
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.search_active {
self.close_search(window, cx);
} else {
self.open_search(window, cx);
}
}
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = true;
// Focus the search input on the next frame so it happens after the
// current render cycle completes, avoiding focus being stolen back
// by the terminal panel's track_focus.
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
cx.notify();
}
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = false;
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
self.search_bar_bounds = None;
self.focus_handle.focus(window, cx);
cx.notify();
}
/// Move keyboard focus back to the search input so the user can keep typing.
/// Deferred to the next frame so it happens after the current render cycle,
/// preventing the terminal panel's track_focus from stealing focus back.
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
}
pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = self.search_input.read(cx).text().to_string();
if query.is_empty() {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.refocus_search_input(window, cx);
cx.notify();
return;
}
// Find the active tab — try active_tab first, then fall back to the
// first tab in the active group, then any tab.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
self.status = t!("no_results").into();
self.refocus_search_input(window, cx);
cx.notify();
return;
};
// Remember which tab was searched so highlights only appear in that pane.
self.search_target_tab = Some(tab.id.clone());
let query_lower = query.to_lowercase();
let query_byte_len = query.len();
// Search the ENTIRE terminal buffer (scrollback + visible screen).
// full_grid_rows returns grid line indices; the first row is at
// `grid_start` (typically -history_size).
let (grid_start, all_rows) = tab.full_grid_rows();
let mut matches: Vec<(i32, i32)> = Vec::new();
for (row_idx, row) in all_rows.iter().enumerate() {
if row.is_empty() {
continue;
}
// Build text string and byte→column index mapping.
let mut text = String::with_capacity(row.len());
let mut byte_to_col: Vec<i32> = Vec::new();
for &(col, c) in row {
text.push(c);
while byte_to_col.len() < text.len() {
byte_to_col.push(col);
}
}
let text_lower = text.to_lowercase();
// Grid line index for this row.
let abs_row = grid_start + row_idx as i32;
let mut search_start = 0;
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
let abs = search_start + pos;
let start_col = byte_to_col[abs];
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
let end_col = byte_to_col[end_byte - 1];
for c in start_col..=end_col {
matches.push((abs_row, c));
}
search_start = abs + query_byte_len;
}
}
let match_count = count_match_groups(&matches);
self.search_query = query;
self.search_matches = matches;
if match_count > 0 {
self.search_current = 0;
self.jump_to_current_match(cx);
}
self.status = format!(
"{}: {} ({})",
t!("search"),
self.search_query,
if match_count == 0 {
t!("no_results").to_string()
} else {
format!("{}/{}", self.search_current + 1, match_count)
}
)
.into();
// Keep focus on the search input so the user can continue typing.
self.refocus_search_input(window, cx);
cx.notify();
}
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + match_count - 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
// target_grid_line is the grid line index (negative = history).
let Some((target_grid_line, _)) =
find_nth_match_start(&self.search_matches, self.search_current)
else {
return;
};
// Find the tab ID first (immutable borrow), then look up mutably.
let tab_id = self.active_tab.clone().or_else(|| {
self.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string())
});
let tab = if let Some(id) = tab_id.as_deref() {
self.tabs.iter_mut().find(|t| t.id == id)
} else {
self.tabs.first_mut()
};
if let Some(tab) = tab {
let snapshot = tab.render_snapshot();
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
// viewport row = grid_line + display_offset
let vp_row = target_grid_line + display_offset;
let visible = vp_row >= 0 && vp_row < rows;
if !visible {
// Scroll so the target grid line appears near the top.
// display_offset = -grid_line puts it at viewport row 0.
let new_offset = (-target_grid_line).max(0) as usize;
if new_offset > snapshot.display_offset {
tab.scroll_up_by(new_offset - snapshot.display_offset);
} else if new_offset < snapshot.display_offset {
tab.scroll_down_by(snapshot.display_offset - new_offset);
}
}
}
}
/// Build a highlight map for search matches, converting grid line indices
/// to the current viewport coordinates. Only returns highlights for the
/// pane that was actually searched (`search_target_tab`).
pub(crate) fn search_highlight_map(
&self,
tab_id: &str,
match_color: Hsla,
current_color: Hsla,
) -> Option<HashMap<(i32, i32), Hsla>> {
if self.search_matches.is_empty()
|| self.search_query.is_empty()
|| self.search_target_tab.as_deref() != Some(tab_id)
{
return None;
}
// Get current display_offset to convert grid line → viewport row.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
return None;
};
let snapshot = tab.render_snapshot();
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
let mut map = HashMap::new();
let mut sorted: Vec<(i32, i32)> = self.search_matches.clone();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
let is_current = group_idx == self.search_current;
let color = if is_current {
current_color
} else {
match_color
};
// grid_line → viewport row: vp_row = grid_line + display_offset
let (grid_line, _) = sorted[i];
let vp_row = grid_line + display_offset;
let mut j = i;
if vp_row >= 0 && vp_row < rows {
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
map.insert((vp_row, sorted[j].1), color);
j += 1;
}
} else {
// Outside current viewport — skip.
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
j += 1;
}
}
group_idx += 1;
i = j;
}
Some(map)
}
/// Render the search button (used in the tab bar).
pub(crate) fn render_search_button(
&self,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
div()
.child(
Button::new("search-btn")
.ghost()
.small()
.rounded(px(999.))
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_search(window, cx);
})),
)
}
/// Render the expanded search bar overlay (when search is active).
pub(crate) fn render_search_bar(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
let match_count = count_match_groups(&self.search_matches);
let has_query = !self.search_query.is_empty();
let has_matches = match_count > 0;
let current_display = if has_matches {
format!("{}/{}", self.search_current + 1, match_count)
} else if has_query {
"0".to_string()
} else {
String::new()
};
let view = cx.entity();
div()
.absolute()
.top(px(8.))
.right(px(24.))
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.search_bar_bounds = Some(bounds);
});
})
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| {
this.refocus_search_input(window, cx);
cx.stop_propagation();
}))
.child(
h_flex()
.gap_1()
.items_center()
.p_1()
.rounded(px(6.))
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.child(
div()
.w(px(200.))
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
if event.keystroke.key.as_str() == "escape" {
this.close_search(window, cx);
window.prevent_default();
cx.stop_propagation();
}
}))
.child(Input::new(&self.search_input).small()),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
.child(current_display),
)
})
.child(
Button::new("search-prev")
.ghost()
.xsmall()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_prev(cx);
})),
)
.child(
Button::new("search-next")
.ghost()
.xsmall()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_next(cx);
})),
)
.child(
Button::new("search-close")
.ghost()
.xsmall()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, window, cx| {
this.close_search(window, cx);
})),
),
)
.into_any_element()
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Count distinct match groups in a sorted list of (row, col) positions.
/// A group is a run of consecutive columns in the same row.
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
if matches.is_empty() {
return 0;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut count = 0;
let mut i = 0;
while i < sorted.len() {
count += 1;
let (r, _) = sorted[i];
i += 1;
// Skip consecutive columns in the same row.
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
count
}
/// Find the (row, col) start of the Nth distinct match group.
fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> {
if matches.is_empty() {
return None;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
if group_idx == n {
return Some(sorted[i]);
}
group_idx += 1;
let (r, _) = sorted[i];
i += 1;
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
None
}
+1
View File
@@ -9,6 +9,7 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/tokyonight.json"),
include_str!("../../assets/themes/gruvbox.json"),
include_str!("../../assets/themes/solarized.json"),
include_str!("../../assets/themes/phygerr.json"),
];
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
+45 -31
View File
@@ -2156,43 +2156,51 @@ impl Ashell {
cx.stop_propagation();
this.split_current_pane("right", cx);
})),
),
)
.child(self.render_search_button(cx)),
)
}
fn render_terminal_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
fn render_terminal_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let has_active = self.active_tab.is_some();
let pane_tree = self.pane_root.clone();
let view = cx.entity();
v_flex().size_full().child(
div()
.size_full()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_panel_bounds = Some(bounds);
});
})
.overflow_hidden()
.track_focus(&self.focus_handle)
.key_context(TERMINAL_KEY_CONTEXT)
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
.on_mouse_down(
MouseButton::Right,
cx.listener(Self::on_terminal_right_click),
)
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
.on_key_down(cx.listener(Self::on_terminal_key_down))
.on_action(cx.listener(Self::on_terminal_tab_action))
.on_action(cx.listener(Self::on_terminal_backtab_action))
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
.child(if has_active {
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
} else {
self.render_home_page(cx).into_any_element()
}),
)
div()
.size_full()
.relative()
.child(
div()
.size_full()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_panel_bounds = Some(bounds);
});
})
.overflow_hidden()
.track_focus(&self.focus_handle)
.key_context(TERMINAL_KEY_CONTEXT)
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
.on_mouse_down(
MouseButton::Right,
cx.listener(Self::on_terminal_right_click),
)
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
.on_key_down(cx.listener(Self::on_terminal_key_down))
.on_action(cx.listener(Self::on_terminal_tab_action))
.on_action(cx.listener(Self::on_terminal_backtab_action))
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
.child(if has_active {
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
} else {
self.render_home_page(cx).into_any_element()
}),
)
// Search bar overlay — only when search is active.
.when(self.search_active, |el| {
el.child(self.render_search_bar(window, cx))
})
}
fn render_pane_tree(
@@ -2252,6 +2260,11 @@ impl Ashell {
font_size,
line_height,
cell_width,
this.search_highlight_map(
tab_id,
cx.theme().danger.opacity(0.35),
cx.theme().danger.opacity(0.70),
),
));
let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default();
el = el.vertical_scrollbar(scrollbar);
@@ -2489,7 +2502,7 @@ impl Render for Ashell {
let body_panel = v_resizable("ashell-body")
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(cx)))
.child(resizable_panel().child(self.render_terminal_panel(window, cx)))
.child(
resizable_panel()
.size(sftp_size)
@@ -2594,6 +2607,7 @@ impl Render for Ashell {
.on_action(cx.listener(|this, _: &crate::OpenSession, window, cx| this.show_selector_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::OpenTransfers, window, cx| this.show_transfers_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::NewSsh, window, cx| this.show_ssh_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::OpenSearch, window, cx| this.toggle_search(window, cx)))
.on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| {
this.sidebar_collapsed = !this.sidebar_collapsed;
this.config.set_sidebar_collapsed(this.sidebar_collapsed);
+3 -3
View File
@@ -16,9 +16,9 @@ rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp,
ToggleSftpZoom, ToggleSidebar,
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::{
+28 -1
View File
@@ -805,6 +805,15 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the search bar is visible and the click is inside it, let the
// search bar handle the event instead of switching pane focus.
if self.search_active {
if let Some(bounds) = self.search_bar_bounds {
if bounds.contains(&event.position) {
return;
}
}
}
self.focus_handle.focus(window, cx);
// Check if click is in a different pane and focus it
let click_pos = event.position;
@@ -1052,7 +1061,16 @@ impl Ashell {
if let Some(new_path) = Self::find_adjacent_pane(&self.pane_root, &path, direction) {
self.focused_pane_path = new_path;
if let Some(id) = self.pane_root.focused_tab_id(&self.focused_pane_path) {
self.active_tab = Some(id.to_string());
let id_owned = id.to_string();
let changed = self.active_tab.as_deref() != Some(id_owned.as_str());
self.active_tab = Some(id_owned);
// Clear stale search state when switching to a different pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
}
}
@@ -1335,8 +1353,17 @@ impl Ashell {
}
let mut path = Vec::new();
if find_path(&self.pane_root, &tab_id, &mut path) {
let changed = self.active_tab.as_deref() != Some(tab_id.as_str());
self.focused_pane_path = path;
self.active_tab = Some(tab_id);
// Clear stale search state when switching to a different pane.
// The user can press Enter to re-search in the new pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
}
}
+391
View File
@@ -1,5 +1,9 @@
use std::collections::HashMap;
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
use super::RenderCell;
pub fn is_custom_block_supported(c: char) -> bool {
match c as u32 {
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
@@ -335,3 +339,390 @@ pub fn paint_custom_block(
painted
}
// ---------------------------------------------------------------------------
// Terminal keyword / pattern highlighting
// ---------------------------------------------------------------------------
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
a: 1.0,
}
.into_rgba_like(r, g, b)
}
trait HslaExt {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
}
impl HslaExt for Hsla {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let l = (max + min) / 2.0;
if max == min {
return Hsla {
h: 0.0,
s: 0.0,
l,
a: 1.0,
};
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == rf {
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
} else if max == gf {
((bf - rf) / d + 2.0) / 6.0
} else {
((rf - gf) / d + 4.0) / 6.0
};
Hsla { h, s, l, a: 1.0 }
}
}
/// Highlight colors for common terminal keywords and patterns.
struct HighlightColors {
error: Hsla, // ERROR, FATAL, CRITICAL, PANIC
success: Hsla, // SUCCESS, OK, PASS
warning: Hsla, // WARN, WARNING
info: Hsla, // INFO, NOTICE
failure: Hsla, // FAIL, FAILED, DENIED, REJECTED, TIMEOUT
network: Hsla, // IP addresses
url: Hsla, // http://, https://
port: Hsla, // :22, :443, etc.
debug: Hsla, // DEBUG, DBG, TRACE
}
fn highlight_colors() -> HighlightColors {
HighlightColors {
error: hsla(224, 96, 96), // #E06060 red
success: hsla(126, 198, 153), // #7EC699 green
warning: hsla(232, 201, 122), // #E8C97A yellow
info: hsla(108, 180, 238), // #6CB4EE blue
failure: hsla(232, 168, 124), // #E8A87C orange
network: hsla(199, 146, 234), // #C792EA purple
url: hsla( 86, 212, 199), // #56D4C7 teal
port: hsla(130, 170, 200), // #82AAC8 muted teal
debug: hsla(130, 140, 155), // #828C9B gray
}
}
/// Check if `c` is a word boundary (not alphanumeric or underscore).
fn is_boundary(c: char) -> bool {
!c.is_ascii_alphanumeric() && c != '_'
}
/// Scan terminal cells for common keywords and patterns, returning a map of
/// `(row, col) -> highlight_color` for cells that should be recolored.
/// If `search_map` is provided, search match colors take priority over keyword colors.
pub fn highlight_cells(
cells: &[RenderCell],
rows: usize,
search_map: Option<&HashMap<(i32, i32), Hsla>>,
) -> HashMap<(i32, i32), Hsla> {
let colors = highlight_colors();
// Build a per-row char array with cell column tracking.
// row_chars[row] = Vec<(col, char)>
let mut row_chars: Vec<Vec<(i32, char)>> = vec![vec![]; rows];
for rc in cells {
if rc.row < 0 || (rc.row as usize) >= rows {
continue;
}
row_chars[rc.row as usize].push((rc.col, rc.cell.c));
}
for row in row_chars.iter_mut() {
row.sort_by_key(|&(col, _)| col);
}
let mut map = HashMap::new();
for (row_idx, row) in row_chars.iter().enumerate() {
if row.is_empty() {
continue;
}
let row_i32 = row_idx as i32;
// Build the text string and a byte-offset → column index lookup.
let mut chars_buf = String::with_capacity(row.len());
let mut byte_to_col: Vec<i32> = Vec::new();
for &(col, c) in row {
chars_buf.push(c);
// Pad the mapping so every byte of this char points to `col`.
while byte_to_col.len() < chars_buf.len() {
byte_to_col.push(col);
}
}
let text = chars_buf.as_str();
// ── 1. Error keywords (highest priority) ──────────────────────────
for kw in &["EMERGENCY", "CRITICAL", "FATAL", "PANIC", "ERROR", "ERR"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.error);
}
}
}
// ── 2. Success keywords ───────────────────────────────────────────
for kw in &["SUCCESS", "SUCCEEDED", "PASSED", "PASS", "OK"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.success);
}
}
}
// ── 3. Failure keywords ───────────────────────────────────────────
for kw in &["FAILED", "FAILURE", "DENIED", "REJECTED", "TIMEOUT", "FAIL"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.failure);
}
}
}
// ── 4. Warning keywords ───────────────────────────────────────────
for kw in &["WARNING", "WARN"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.warning);
}
}
}
// ── 5. Info keywords ──────────────────────────────────────────────
for kw in &["NOTICE", "INFO"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.info);
}
}
}
// ── 6. Debug keywords ─────────────────────────────────────────────
for kw in &["DEBUG", "DBG", "TRACE"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.debug);
}
}
}
// ── 7. IP addresses ───────────────────────────────────────────────
for m in find_ip_addresses(text) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + find_ip_len(&text[m..])).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.network);
}
}
// ── 8. URLs ───────────────────────────────────────────────────────
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + url_len).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.url);
}
}
// ── 9. Port numbers (:digits) ─────────────────────────────────────
for m in find_ports(text) {
let port_len = find_port_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + port_len).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.port);
}
}
}
// Merge search highlight map — search colors override keyword colors.
if let Some(sm) = search_map {
for (key, color) in sm {
map.insert(*key, *color);
}
}
map
}
/// Find all occurrences of `keyword` as a whole word in `text`.
/// Returns byte offsets.
fn find_keyword(text: &str, keyword: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = text[start..].find(keyword) {
let abs = start + pos;
let before_ok = abs == 0
|| text.as_bytes()[abs - 1] == b' '
|| is_boundary(text.as_bytes()[abs - 1] as char);
let after_pos = abs + keyword.len();
let after_ok = after_pos >= text.len()
|| text.as_bytes()[after_pos] == b' '
|| is_boundary(text.as_bytes()[after_pos] as char);
if before_ok && after_ok {
positions.push(abs);
}
start = abs + keyword.len();
}
positions
}
/// Check if `text` starts with a valid IP address. Returns byte length if so.
fn find_ip_len(text: &str) -> usize {
let bytes = text.as_bytes();
let mut dots = 0u8;
let mut digits = 0u8;
let mut len = 0usize;
for &b in bytes {
match b {
b'0'..=b'9' => {
digits += 1;
if digits > 3 {
return 0;
}
}
b'.' => {
if digits == 0 {
return 0;
}
dots += 1;
if dots > 3 {
return 0;
}
digits = 0;
}
_ => break,
}
len += 1;
}
if dots == 3 && digits > 0 {
len
} else {
0
}
}
/// Find byte offsets of IP addresses in text.
fn find_ip_addresses(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let bytes = text.as_bytes();
let len = bytes.len();
for i in 0..len {
if bytes[i].is_ascii_digit()
&& (i == 0 || is_boundary(bytes[i - 1] as char))
{
let remaining = &text[i..];
let ip_len = find_ip_len(remaining);
if ip_len > 0 {
// Validate each octet is 0-255.
let ip_str = &remaining[..ip_len];
let valid = ip_str
.split('.')
.all(|octet| octet.parse::<u8>().is_ok());
if valid {
positions.push(i);
}
}
}
}
positions
}
/// Find byte offsets of URLs starting with http:// or https://
fn find_urls(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = text[start..].find("http") {
let abs = start + pos;
let remaining = &text[abs..];
if remaining.starts_with("https://") || remaining.starts_with("http://") {
if abs == 0 || is_boundary(text.as_bytes()[abs - 1] as char) {
positions.push(abs);
}
}
start = abs + 4;
}
positions
}
/// Get byte length of a URL token (until whitespace or end of string).
fn find_url_len(text: &str) -> usize {
text.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(text.len())
}
/// Find byte offsets of port patterns like `:22`, `:443`, `:8080`.
fn find_ports(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let bytes = text.as_bytes();
let len = bytes.len();
for i in 0..len {
if bytes[i] == b':'
&& i + 1 < len
&& bytes[i + 1].is_ascii_digit()
&& (i == 0 || is_boundary(bytes[i - 1] as char) || bytes[i - 1] == b' ')
{
let mut j = i + 1;
while j < len && bytes[j].is_ascii_digit() {
j += 1;
}
let port_str = &text[i + 1..j];
if let Ok(port) = port_str.parse::<u16>() {
if port > 0 {
let after_ok = j >= len || is_boundary(bytes[j] as char);
if after_ok {
positions.push(i);
}
}
}
}
}
positions
}
/// Get byte length of a port token starting at ':'.
fn find_port_len(text: &str) -> usize {
if !text.starts_with(':') {
return 0;
}
let mut len = 1;
for b in text.as_bytes()[1..].iter() {
if b.is_ascii_digit() {
len += 1;
} else {
break;
}
}
len
}
+14 -2
View File
@@ -11,7 +11,7 @@ use gpui::{
use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::custom_blocks::{highlight_cells, is_custom_block_supported, paint_custom_block};
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
@@ -132,6 +132,7 @@ pub struct TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub struct PrepaintState {
@@ -270,6 +271,7 @@ impl TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
) -> Self {
Self {
view,
@@ -280,6 +282,7 @@ impl TerminalElement {
font_size,
line_height,
cell_width,
search_highlights,
}
}
@@ -354,6 +357,10 @@ impl TerminalElement {
let mut custom_blocks = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
// Compute keyword / pattern highlight map once per frame.
let highlights =
highlight_cells(&self.snapshot.cells, self.snapshot.rows, self.search_highlights.as_ref());
for render_cell in &self.snapshot.cells {
let cell = &render_cell.cell;
if cell.flags.intersects(
@@ -392,7 +399,12 @@ impl TerminalElement {
continue;
}
let style = self.cell_run_style(cell, cx);
let mut style = self.cell_run_style(cell, cx);
// Apply keyword highlight color if this cell was matched.
if let Some(&hl_color) = highlights.get(&(render_cell.row, render_cell.col)) {
style.color = hl_color;
}
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
+13 -2
View File
@@ -3,8 +3,8 @@ use std::ops::Range;
use alacritty_terminal::index::Side;
use alacritty_terminal::selection::SelectionType;
use gpui::{
ClipboardItem, Context, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
ClipboardItem, Context, Focusable as _, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
};
use crate::{
@@ -19,6 +19,17 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the search input is focused, skip terminal key processing
// so the input can handle text entry, paste, etc. normally.
if self
.search_input
.read(cx)
.focus_handle(cx)
.is_focused(window)
{
return;
}
// Pane navigation: Alt + h/j/k/l
if event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.shift
+29 -1
View File
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
use alacritty_terminal::{
event::{Event, EventListener},
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
index::{Column, Line, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
@@ -339,6 +339,34 @@ impl TerminalTab {
}
}
/// Return `(grid_line_base, rows_data)` for the **entire** terminal buffer
/// including scrollback history. `grid_line_base` is the grid line index of
/// the first row (typically `-history_size`). Each entry in `rows_data` is
/// a sorted `Vec<(col, char)>` for that row.
pub fn full_grid_rows(&self) -> (i32, Vec<Vec<(i32, char)>>) {
let grid = self.term.grid();
let history = grid.history_size() as i32;
let screen = grid.screen_lines() as i32;
let total = history + screen;
let cols = self.cols as i32;
let start_line = -history;
let mut rows_data: Vec<Vec<(i32, char)>> = Vec::with_capacity(total as usize);
for line_idx in start_line..(start_line + total) {
let line = Line(line_idx);
let mut cells: Vec<(i32, char)> = Vec::new();
for col_idx in 0..cols {
let point = Point::new(line, Column(col_idx as usize));
let c = grid[point].c;
if c != ' ' && c != '\0' {
cells.push((col_idx, c));
}
}
rows_data.push(cells);
}
(start_line, rows_data)
}
pub fn scroll_history(&mut self, delta: i32) {
if delta != 0 {
self.term.scroll_display(Scroll::Delta(delta));