1048 lines
37 KiB
Rust
1048 lines
37 KiB
Rust
use crate::appearance::Appearance;
|
|
use crate::pane_group::SplitPaneState;
|
|
use crate::settings::EnforceMinimumContrast;
|
|
use crate::terminal::blockgrid_renderer::GridRenderParams;
|
|
use crate::terminal::find::TerminalFindModel;
|
|
use crate::terminal::grid_renderer::CellGlyphCache;
|
|
use crate::terminal::meta_shortcuts::handle_keystroke_despite_composing;
|
|
use crate::terminal::model::escape_sequences::{
|
|
maybe_kitty_keyboard_escape_sequence, KeystrokeWithDetails, ToEscapeSequence,
|
|
};
|
|
use crate::terminal::model::grid::grid_handler::{Link, TermMode};
|
|
use crate::terminal::model::grid::{Dimensions, RespectDisplayedOutput};
|
|
use crate::terminal::model::index::Point;
|
|
use crate::terminal::model::mouse::{MouseAction, MouseButton, MouseState};
|
|
use crate::terminal::model::selection::{SelectAction, SelectionPoint};
|
|
use crate::terminal::model::terminal_model::WithinModel;
|
|
use crate::terminal::model::SecretHandle;
|
|
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
|
use crate::terminal::shared_session::presence_manager::{
|
|
text_selection_color, PresenceManager, MUTED_PARTICIPANT_COLOR,
|
|
};
|
|
use crate::terminal::view::{
|
|
ActiveSessionState, TerminalAction, TerminalEditor, TerminalViewRenderContext,
|
|
};
|
|
use crate::terminal::{grid_renderer, SizeInfo};
|
|
use crate::terminal::{heights_approx_eq, TerminalModel};
|
|
use num_traits::Float as _;
|
|
use parking_lot::FairMutex;
|
|
use pathfinder_geometry::vector::vec2f;
|
|
use vec1::Vec1;
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxy_util::user_input::UserInput;
|
|
use galaxyui::elements::new_scrollable::{NewScrollableElement, ScrollableAxis};
|
|
use galaxyui::event::{KeyState, ModifiersState};
|
|
use galaxyui::platform::keyboard::KeyCode;
|
|
use galaxyui::text::SelectionType;
|
|
|
|
use super::{should_intercept_mouse, should_intercept_scroll};
|
|
use std::ops::{Deref as _, Range};
|
|
use std::sync::Arc;
|
|
use galaxyui::elements::{Axis, Fill, Point as UiPoint, ScrollData, ScrollableElement};
|
|
use galaxyui::fonts::Properties;
|
|
use galaxyui::geometry::rect::RectF;
|
|
use galaxyui::geometry::vector::Vector2F;
|
|
use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels};
|
|
use galaxyui::{
|
|
end_trace,
|
|
event::{DispatchedEvent, InBoundsExt},
|
|
record_trace_event, start_trace, AfterLayoutContext, AppContext, Element, Event, EventContext,
|
|
LayoutContext, PaintContext, SizeConstraint,
|
|
};
|
|
use galaxyui::{ClipBounds, EntityId, ModelHandle};
|
|
|
|
const CLI_SUBAGENT_HORIZONTAL_MARGIN: f32 = 8.;
|
|
const CLI_SUBAGENT_VERTICAL_MARGIN: f32 = 8.;
|
|
|
|
pub struct AltScreenElement {
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
find_model: ModelHandle<TerminalFindModel>,
|
|
is_terminal_focused: bool,
|
|
is_terminal_selecting: bool,
|
|
size: Option<Vector2F>,
|
|
bounds: Option<RectF>,
|
|
origin: Option<UiPoint>,
|
|
highlighted_url: Option<Link>,
|
|
link_tool_tip: Option<Link>,
|
|
grid_render_params: GridRenderParams,
|
|
|
|
/// Optional handle to a hovered secret.
|
|
hovered_secret: Option<SecretHandle>,
|
|
|
|
/// Used to save the position of the active cursor.
|
|
terminal_view_id: EntityId,
|
|
|
|
pane_state: SplitPaneState,
|
|
active_session_state: ActiveSessionState,
|
|
selection_range: Option<Vec1<Range<Point>>>,
|
|
|
|
presence_manager: Option<ModelHandle<PresenceManager>>,
|
|
|
|
// Fields needed for vertical scrolling for shared session viewer when window is smaller than sharer's
|
|
scroll_top: Lines,
|
|
max_scroll_top: Option<Lines>,
|
|
visible_lines: Option<Lines>,
|
|
|
|
cursor_hint_text: Option<Box<dyn Element>>,
|
|
|
|
cli_subagent_view: Option<Box<dyn Element>>,
|
|
|
|
/// Voice input toggle key code for CLI agent footer integration.
|
|
#[cfg_attr(not(feature = "voice_input"), allow(unused))]
|
|
voice_input_toggle_key_code: Option<KeyCode>,
|
|
}
|
|
|
|
impl AltScreenElement {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
terminal_view_render_context: TerminalViewRenderContext,
|
|
find_model: ModelHandle<TerminalFindModel>,
|
|
enforce_minimum_contrast: EnforceMinimumContrast,
|
|
selection_range: Option<Vec1<Range<Point>>>,
|
|
appearance: &Appearance,
|
|
scroll_top: Lines,
|
|
cursor_hint_text: Option<Box<dyn Element>>,
|
|
cli_subagent_view: Option<Box<dyn Element>>,
|
|
) -> Self {
|
|
let highlighted_url = terminal_view_render_context
|
|
.highlighted_url
|
|
.map(TryInto::try_into)
|
|
.and_then(Result::ok);
|
|
|
|
let link_tool_tip = terminal_view_render_context
|
|
.link_tool_tip
|
|
.map(TryInto::try_into)
|
|
.and_then(Result::ok);
|
|
|
|
Self {
|
|
model,
|
|
find_model,
|
|
is_terminal_focused: terminal_view_render_context.is_terminal_focused,
|
|
is_terminal_selecting: terminal_view_render_context.is_terminal_selecting,
|
|
terminal_view_id: terminal_view_render_context.terminal_view_id,
|
|
selection_range,
|
|
size: None,
|
|
bounds: None,
|
|
origin: None,
|
|
hovered_secret: terminal_view_render_context.hovered_secret,
|
|
highlighted_url,
|
|
link_tool_tip,
|
|
pane_state: terminal_view_render_context.pane_state,
|
|
active_session_state: terminal_view_render_context.active_session_state,
|
|
grid_render_params: GridRenderParams {
|
|
warp_theme: appearance.theme().clone(),
|
|
font_family: appearance.monospace_font_family(),
|
|
font_size: appearance.monospace_font_size(),
|
|
font_weight: appearance.monospace_font_weight(),
|
|
line_height_ratio: appearance.ui_builder().line_height_ratio(),
|
|
enforce_minimum_contrast,
|
|
obfuscate_secrets: terminal_view_render_context.obfuscate_secrets,
|
|
size_info: terminal_view_render_context.size_info,
|
|
cell_size: Vector2F::new(
|
|
terminal_view_render_context
|
|
.size_info
|
|
.cell_width_px()
|
|
.as_f32(),
|
|
terminal_view_render_context
|
|
.size_info
|
|
.cell_height_px()
|
|
.as_f32(),
|
|
),
|
|
use_ligature_rendering: false,
|
|
hide_cursor_cell: false,
|
|
},
|
|
presence_manager: None,
|
|
scroll_top,
|
|
visible_lines: None,
|
|
max_scroll_top: None,
|
|
cursor_hint_text,
|
|
cli_subagent_view,
|
|
voice_input_toggle_key_code: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_ligature_rendering(mut self) -> Self {
|
|
self.grid_render_params.use_ligature_rendering = true;
|
|
self
|
|
}
|
|
|
|
pub fn with_hide_cursor_cell(mut self) -> Self {
|
|
self.grid_render_params.hide_cursor_cell = true;
|
|
self
|
|
}
|
|
|
|
pub fn with_shared_session_presence(
|
|
mut self,
|
|
presence_manager: Option<ModelHandle<PresenceManager>>,
|
|
) -> Self {
|
|
self.presence_manager = presence_manager;
|
|
self
|
|
}
|
|
|
|
/// Sets the voice input toggle key code for CLI agent footer integration.
|
|
#[cfg(feature = "voice_input")]
|
|
pub fn with_voice_input_toggle_key(mut self, key_code: Option<KeyCode>) -> Self {
|
|
self.voice_input_toggle_key_code = key_code;
|
|
self
|
|
}
|
|
|
|
fn key_down(&mut self, chars: &str, ctx: &mut EventContext) -> bool {
|
|
if self.is_terminal_focused && !chars.is_empty() && chars.chars().all(|c| c.is_control()) {
|
|
ctx.dispatch_typed_action(TerminalAction::KeyDown(chars.to_string()));
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn typed_characters(&mut self, chars: &str, ctx: &mut EventContext) -> bool {
|
|
if self.is_terminal_focused && !chars.is_empty() {
|
|
ctx.dispatch_typed_action(TerminalAction::TypedCharacters(chars.to_string()));
|
|
}
|
|
true
|
|
}
|
|
|
|
fn set_marked_text(
|
|
&mut self,
|
|
marked_text: &str,
|
|
selected_range: &Range<usize>,
|
|
ctx: &mut EventContext,
|
|
) -> bool {
|
|
if self.is_terminal_focused {
|
|
ctx.dispatch_typed_action(TerminalAction::SetMarkedText {
|
|
marked_text: UserInput::new(marked_text),
|
|
selected_range: selected_range.clone(),
|
|
});
|
|
}
|
|
true
|
|
}
|
|
|
|
fn clear_marked_text(&mut self, ctx: &mut EventContext) -> bool {
|
|
if self.is_terminal_focused {
|
|
ctx.dispatch_typed_action(TerminalAction::ClearMarkedText);
|
|
}
|
|
true
|
|
}
|
|
|
|
fn drag_and_drop_file(&mut self, paths: &[String], ctx: &mut EventContext) -> bool {
|
|
if self.is_terminal_focused && !paths.is_empty() {
|
|
let paths = paths.iter().map(ToOwned::to_owned).collect();
|
|
ctx.dispatch_typed_action(TerminalAction::DragAndDropFiles(paths));
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn middle_mouse_down(&self, local_position: Vector2F, ctx: &mut EventContext) -> bool {
|
|
let point = self.coord_to_point(local_position);
|
|
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnGrid {
|
|
position: Some(WithinModel::AltScreen(Point {
|
|
col: point.col,
|
|
row: point.row,
|
|
})),
|
|
});
|
|
true
|
|
}
|
|
|
|
fn left_mouse_down(
|
|
&self,
|
|
mouse_state: MouseState,
|
|
local_position: Vector2F,
|
|
click_count: u32,
|
|
ctx: &mut EventContext,
|
|
app: &AppContext,
|
|
) -> bool {
|
|
if !self.pane_state.is_focused() {
|
|
return false;
|
|
}
|
|
|
|
ctx.dispatch_typed_action(TerminalAction::Focus);
|
|
|
|
// On mobile, request soft keyboard so users can input.
|
|
if galaxyui::platform::is_mobile_device() {
|
|
ctx.request_soft_keyboard();
|
|
}
|
|
|
|
let point = self.coord_to_point(local_position);
|
|
let side = self
|
|
.grid_render_params
|
|
.size_info
|
|
.get_mouse_side(local_position);
|
|
let selection_type = if FeatureFlag::RectSelection.is_enabled() {
|
|
SelectionType::from_mouse_event(*mouse_state.modifiers(), click_count)
|
|
} else {
|
|
SelectionType::from_click_count(click_count)
|
|
};
|
|
|
|
if should_intercept_mouse(&self.model.lock(), mouse_state.modifiers().shift, app) {
|
|
ctx.dispatch_typed_action(TerminalAction::AltSelect(SelectAction::Begin {
|
|
point,
|
|
side,
|
|
selection_type,
|
|
position: local_position,
|
|
}));
|
|
} else {
|
|
ctx.dispatch_typed_action(TerminalAction::MaybeClearAltSelect);
|
|
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
|
|
}
|
|
true
|
|
}
|
|
|
|
fn right_mouse_down(
|
|
&self,
|
|
mouse_state: MouseState,
|
|
local_position: Vector2F,
|
|
ctx: &mut EventContext,
|
|
app: &AppContext,
|
|
) -> bool {
|
|
if self.active_session_state != ActiveSessionState::Active {
|
|
return false;
|
|
}
|
|
|
|
let point = self.coord_to_point(local_position);
|
|
|
|
if should_intercept_mouse(&self.model.lock(), mouse_state.modifiers().shift, app) {
|
|
ctx.dispatch_typed_action(TerminalAction::AltScreenContextMenu {
|
|
position: local_position,
|
|
});
|
|
} else {
|
|
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
|
|
}
|
|
true
|
|
}
|
|
|
|
fn mouse_moved(
|
|
&self,
|
|
local_position: Vector2F,
|
|
is_synthetic: bool,
|
|
app: &AppContext,
|
|
ctx: &mut EventContext,
|
|
) -> bool {
|
|
if self.active_session_state != ActiveSessionState::Active {
|
|
return false;
|
|
}
|
|
|
|
let point = self.coord_to_point(local_position);
|
|
|
|
// If SGR_MOUSE is set -- we consider user to be in an editor like vim or nano.
|
|
let from_editor = match self.model.lock().is_term_mode_set(TermMode::SGR_MOUSE) {
|
|
true => TerminalEditor::Yes,
|
|
false => TerminalEditor::No,
|
|
};
|
|
|
|
let grid_point = WithinModel::AltScreen(Point {
|
|
col: point.col,
|
|
row: point.row,
|
|
});
|
|
if get_secret_obfuscation_mode(app).is_visually_obfuscated() {
|
|
let secret_handle = self
|
|
.model
|
|
.lock()
|
|
.secret_at_point(&grid_point)
|
|
.map(|(handle, _)| handle);
|
|
ctx.dispatch_typed_action(TerminalAction::MaybeHoverSecret { secret_handle });
|
|
}
|
|
|
|
ctx.dispatch_typed_action(TerminalAction::MaybeLinkHover {
|
|
position: Some(grid_point),
|
|
from_editor,
|
|
});
|
|
|
|
// For alt-screen PTY purposes, we ignore synthetic mouse events!
|
|
// This is especially relevant for mouse drags - we do not want mouse moved
|
|
// events being handled at the same time as mouse dragged events.
|
|
if !is_synthetic && self.model.lock().is_term_mode_set(TermMode::MOUSE_MOTION) {
|
|
// Note: it is counter-intuitive to use "pressed" as a state here for mouse hover motions, however,
|
|
// we're largely just following standards from Alacritty/other terminals and the original terminal specs.
|
|
// We intend to combine the mouse button and mouse action enums to avoid "weird" or "impossible" combinations,
|
|
// see Linear issue at https://linear.app/warpdotdev/issue/CORE-1039/combine-the-mousebutton-and-mouseaction-enums-to-avoid-impossible.
|
|
let mouse_state =
|
|
MouseState::new(MouseButton::Move, MouseAction::Pressed, Default::default());
|
|
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
|
|
}
|
|
|
|
// Allow the event to continue propagating.
|
|
false
|
|
}
|
|
|
|
/// Called when the mouse is moved outside of the element.
|
|
fn mouse_out(&self, ctx: &mut EventContext) -> bool {
|
|
ctx.dispatch_typed_action(TerminalAction::MaybeLinkHover {
|
|
position: None,
|
|
from_editor: TerminalEditor::No,
|
|
});
|
|
true
|
|
}
|
|
|
|
fn mouse_up(
|
|
&self,
|
|
mouse_state: MouseState,
|
|
local_position: Vector2F,
|
|
ctx: &mut EventContext,
|
|
app: &AppContext,
|
|
) -> bool {
|
|
if self.active_session_state != ActiveSessionState::Active {
|
|
return false;
|
|
}
|
|
|
|
let point = self.coord_to_point(local_position);
|
|
|
|
ctx.dispatch_typed_action(TerminalAction::ClickOnGrid {
|
|
position: WithinModel::AltScreen(Point {
|
|
col: point.col,
|
|
row: point.row,
|
|
}),
|
|
modifiers: *mouse_state.modifiers(),
|
|
});
|
|
|
|
if self.is_terminal_selecting {
|
|
ctx.dispatch_typed_action(TerminalAction::AltSelect(SelectAction::End));
|
|
}
|
|
|
|
if !should_intercept_mouse(&self.model.lock(), mouse_state.modifiers().shift, app) {
|
|
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn mouse_dragged(
|
|
&self,
|
|
mouse_state: MouseState,
|
|
local_position: Vector2F,
|
|
ctx: &mut EventContext,
|
|
app: &AppContext,
|
|
) -> bool {
|
|
if self.active_session_state != ActiveSessionState::Active {
|
|
return false;
|
|
}
|
|
|
|
let mut is_mouse_dragged = false;
|
|
let point = self.coord_to_point(local_position);
|
|
|
|
if self.is_terminal_selecting && self.bounds.is_some() {
|
|
let side = self
|
|
.grid_render_params
|
|
.size_info
|
|
.get_mouse_side(local_position);
|
|
ctx.dispatch_typed_action(TerminalAction::AltSelect(SelectAction::Update {
|
|
point,
|
|
// Scrolling is not yet implemented in AltScreen so leave as 0 for now
|
|
delta: Lines::zero(),
|
|
side,
|
|
position: local_position,
|
|
}));
|
|
is_mouse_dragged = true;
|
|
}
|
|
if !should_intercept_mouse(&self.model.lock(), mouse_state.modifiers().shift, app) {
|
|
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
|
|
}
|
|
is_mouse_dragged
|
|
}
|
|
|
|
fn on_scroll(
|
|
&mut self,
|
|
local_position: Vector2F,
|
|
delta: Vector2F,
|
|
precise: bool,
|
|
ctx: &mut EventContext,
|
|
app: &AppContext,
|
|
) -> bool {
|
|
let cell_height = self.grid_render_params.size_info.cell_height_px;
|
|
let delta = if precise {
|
|
// Handle Trackpad Scroll by converting pixel height into lines.
|
|
delta.y().into_pixels().to_lines(cell_height)
|
|
} else {
|
|
// Handle Mouse Scroll, whose delta is already in terms of lines.
|
|
delta.y().into_lines()
|
|
};
|
|
|
|
// The alt screen can be vertically scrollable iff we're a shared session reader
|
|
// and our window is smaller than the sharer's.
|
|
if self.model.lock().shared_session_status().is_reader() {
|
|
ScrollableElement::scroll(self, delta.to_pixels(cell_height), ctx);
|
|
}
|
|
|
|
ctx.dispatch_typed_action(TerminalAction::MaybeDismissToolTip {
|
|
from_keybinding: false,
|
|
});
|
|
|
|
let delta = self
|
|
.model
|
|
.lock()
|
|
.alt_screen_mut()
|
|
.accumulate_lines_to_scroll(delta);
|
|
|
|
if should_intercept_scroll(&self.model.lock(), app) {
|
|
ctx.dispatch_typed_action(TerminalAction::AltScroll { delta });
|
|
} else {
|
|
let point = self.coord_to_point(local_position);
|
|
|
|
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(
|
|
MouseState::new(
|
|
MouseButton::Wheel,
|
|
MouseAction::Scrolled { delta },
|
|
Default::default(),
|
|
)
|
|
.set_point(point),
|
|
));
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Return a Vector2F that can be used to adjust a position to account for vertical scrolling.
|
|
fn vertical_scroll_pixels(&self) -> Vector2F {
|
|
vec2f(
|
|
0.,
|
|
self.scroll_top.as_f64() as f32 * self.line_height().as_f32(),
|
|
)
|
|
}
|
|
|
|
/// Converts a pixel coordinate to a point in the `AltScreen` coordinate space.
|
|
fn coord_to_point(&self, coord: Vector2F) -> Point {
|
|
let model = self.model.lock();
|
|
let grid = model.alt_screen().grid_handler();
|
|
let total_height = grid.total_rows();
|
|
let size = self.grid_render_params.size_info;
|
|
|
|
let column = ((coord.x() - size.padding_x_px.as_f32()) / size.cell_width_px().as_f32())
|
|
.max(0.)
|
|
.min(grid.columns() as f32 - 1.) as usize;
|
|
|
|
let row = (coord.y() / size.cell_height_px().as_f32())
|
|
.max(0.)
|
|
.min(total_height as f32 - 1.) as usize;
|
|
Point::new(row, column)
|
|
}
|
|
|
|
/// Renders our own selection.
|
|
fn render_selections(&self, size_info: &SizeInfo, origin: Vector2F, ctx: &mut PaintContext) {
|
|
let text_selection_color = self
|
|
.grid_render_params
|
|
.warp_theme
|
|
.text_selection_color()
|
|
.into_solid();
|
|
|
|
if let Some(ranges) = &self.selection_range {
|
|
for range in ranges {
|
|
let start = SelectionPoint {
|
|
row: range.start.row.into_lines(),
|
|
col: range.start.col,
|
|
};
|
|
let end = SelectionPoint {
|
|
row: range.end.row.into_lines(),
|
|
col: range.end.col,
|
|
};
|
|
grid_renderer::render_selection(
|
|
&start,
|
|
&end,
|
|
size_info,
|
|
Lines::zero(),
|
|
origin,
|
|
text_selection_color,
|
|
ctx,
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Renders any shared session participants' selections.
|
|
fn render_participant_selections(
|
|
&self,
|
|
size_info: &SizeInfo,
|
|
origin: Vector2F,
|
|
ctx: &mut PaintContext,
|
|
app: &AppContext,
|
|
) {
|
|
if let Some(presence_manager) = &self.presence_manager {
|
|
let is_self_reconnecting = presence_manager.as_ref(app).is_reconnecting();
|
|
for participant in presence_manager.as_ref(app).all_present_participants() {
|
|
let session_sharing_protocol::common::Selection::AltScreenText {
|
|
start,
|
|
end,
|
|
is_reversed,
|
|
} = &participant.info.selection
|
|
else {
|
|
continue;
|
|
};
|
|
let start = SelectionPoint {
|
|
row: start.row.into_lines(),
|
|
col: start.col,
|
|
};
|
|
let end = SelectionPoint {
|
|
row: end.row.into_lines(),
|
|
col: end.col,
|
|
};
|
|
let participant_color = if is_self_reconnecting {
|
|
MUTED_PARTICIPANT_COLOR
|
|
} else {
|
|
participant.color
|
|
};
|
|
grid_renderer::render_selection(
|
|
&start,
|
|
&end,
|
|
size_info,
|
|
Lines::zero(),
|
|
origin,
|
|
text_selection_color(participant_color),
|
|
ctx,
|
|
);
|
|
let cursor_point = if *is_reversed { &start } else { &end };
|
|
grid_renderer::render_selection_cursor(
|
|
cursor_point,
|
|
size_info,
|
|
Lines::zero(),
|
|
origin,
|
|
participant_color,
|
|
!*is_reversed,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn total_lines(&self) -> Lines {
|
|
self.grid_render_params.size_info.rows().into_lines()
|
|
}
|
|
|
|
fn line_height(&self) -> Pixels {
|
|
self.grid_render_params.size_info.cell_height_px()
|
|
}
|
|
|
|
#[cfg(feature = "voice_input")]
|
|
fn maybe_handle_voice_toggle(
|
|
&self,
|
|
key_code: &KeyCode,
|
|
state: &KeyState,
|
|
ctx: &mut EventContext,
|
|
) -> bool {
|
|
if let Some(voice_input_toggle_key_code) = self.voice_input_toggle_key_code {
|
|
if *key_code == voice_input_toggle_key_code {
|
|
ctx.dispatch_typed_action(TerminalAction::ToggleCLIAgentVoiceInput(
|
|
voice_input::VoiceInputToggledFrom::Key { state: *state },
|
|
));
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(not(feature = "voice_input"))]
|
|
fn maybe_handle_voice_toggle(
|
|
&self,
|
|
_key_code: &KeyCode,
|
|
_state: &KeyState,
|
|
_ctx: &mut EventContext,
|
|
) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
impl Element for AltScreenElement {
|
|
fn layout(
|
|
&mut self,
|
|
constraint: SizeConstraint,
|
|
ctx: &mut LayoutContext,
|
|
app: &AppContext,
|
|
) -> Vector2F {
|
|
self.size = Some(constraint.max);
|
|
|
|
if let Some(cursor_hint_text) = &mut self.cursor_hint_text {
|
|
cursor_hint_text.layout(constraint, ctx, app);
|
|
}
|
|
|
|
if let Some(cli_subagent_view) = &mut self.cli_subagent_view {
|
|
cli_subagent_view.layout(
|
|
SizeConstraint {
|
|
min: vec2f(0., 0.),
|
|
max: vec2f(
|
|
constraint.max.x() * 0.3 - CLI_SUBAGENT_HORIZONTAL_MARGIN,
|
|
constraint.max.y() - CLI_SUBAGENT_VERTICAL_MARGIN * 3.,
|
|
),
|
|
},
|
|
ctx,
|
|
app,
|
|
);
|
|
}
|
|
|
|
constraint.max
|
|
}
|
|
|
|
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
|
let size = self.size.expect("Size should be set in `layout()`");
|
|
self.visible_lines = Some(size.y().into_pixels().to_lines(self.line_height()).floor());
|
|
self.max_scroll_top = Some(self.total_lines() - self.visible_lines.unwrap());
|
|
// After resizing the window to be larger, the max_scroll_top could have decreased,
|
|
// so we need to make sure scroll_top is in bounds.
|
|
self.scroll_top = self.scroll_top.min(self.max_scroll_top.unwrap());
|
|
|
|
// We want to make sure to call after_layout on each of the elements that were actually laid out.
|
|
if let Some(cli_subagent_view) = &mut self.cli_subagent_view {
|
|
if cli_subagent_view.size().is_some() {
|
|
cli_subagent_view.after_layout(ctx, app);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
|
start_trace!("alt_screen_element:paint");
|
|
self.bounds = Some(RectF::new(
|
|
origin,
|
|
self.size.expect("Size should be set before paint"),
|
|
));
|
|
self.origin = Some(UiPoint::from_vec2f(origin, ctx.scene.z_index()));
|
|
|
|
let model = self.model.lock();
|
|
|
|
let override_colors = model.override_colors();
|
|
|
|
let grid = model.alt_screen().grid_handler();
|
|
|
|
let cell_size = Vector2F::new(
|
|
self.grid_render_params.size_info.cell_width_px().as_f32(),
|
|
self.grid_render_params.size_info.cell_height_px().as_f32(),
|
|
);
|
|
|
|
let mut glyphs = CellGlyphCache::default();
|
|
|
|
let properties = Properties::default();
|
|
|
|
let padding_x = self.grid_render_params.size_info.padding_x_px();
|
|
|
|
let find_model = self.find_model.as_ref(app);
|
|
let find_run = find_model
|
|
.is_find_bar_open()
|
|
.then(|| find_model.alt_screen_find_run())
|
|
.flatten();
|
|
let alt_screen_matches = find_run.map(|run| run.matches().iter().rev());
|
|
let focused_match_range = find_run.and_then(|run| run.focused_match_range());
|
|
|
|
let obfuscate_secrets =
|
|
get_secret_obfuscation_mode(app).and(&grid.get_secret_obfuscation());
|
|
|
|
let mut sampler = model.alt_screen().bg_color_sampler.lock();
|
|
if let Some(bg_color) = sampler.most_common() {
|
|
if !bg_color.is_fully_transparent() {
|
|
if let Some(bounds) = self.bounds {
|
|
ctx.scene
|
|
.draw_rect_without_hit_recording(bounds)
|
|
.with_background(Fill::Solid(bg_color));
|
|
}
|
|
}
|
|
}
|
|
sampler.reset();
|
|
|
|
// Render grid cells. Since the alt screen has no scrollback we can always start at index 0.
|
|
record_trace_event!("alt_screen_element:paint:preparing_to_render_grid");
|
|
let start_row = self.scroll_top.as_f64();
|
|
let end_row = (start_row
|
|
+ self
|
|
.visible_lines
|
|
.expect("should be set after layout")
|
|
.as_f64())
|
|
.min(grid.visible_rows() as f64);
|
|
let adjusted_grid_origin = origin - self.vertical_scroll_pixels();
|
|
let cursor_visible = model.alt_screen().is_mode_set(TermMode::SHOW_CURSOR);
|
|
grid_renderer::render_grid(
|
|
grid,
|
|
start_row.floor() as usize,
|
|
end_row.ceil() as usize,
|
|
&model.colors(),
|
|
&override_colors,
|
|
&self.grid_render_params.warp_theme,
|
|
properties,
|
|
self.grid_render_params.font_family,
|
|
self.grid_render_params.font_size,
|
|
self.grid_render_params.line_height_ratio,
|
|
cell_size,
|
|
padding_x,
|
|
adjusted_grid_origin,
|
|
&mut glyphs,
|
|
255, /* alpha */
|
|
self.highlighted_url.as_ref(),
|
|
self.link_tool_tip.as_ref(),
|
|
alt_screen_matches,
|
|
focused_match_range,
|
|
self.grid_render_params.enforce_minimum_contrast,
|
|
obfuscate_secrets,
|
|
self.hovered_secret,
|
|
self.grid_render_params.use_ligature_rendering,
|
|
cursor_visible.then(|| model.alt_screen().cursor_style().shape),
|
|
RespectDisplayedOutput::Yes,
|
|
&model.image_id_to_metadata,
|
|
Some(&mut sampler),
|
|
self.grid_render_params.hide_cursor_cell,
|
|
ctx,
|
|
app,
|
|
);
|
|
record_trace_event!("alt_screen_element:paint:grid_rendered");
|
|
|
|
// Render cursor if the escape sequence is set.
|
|
// Also suppress the cursor when hide_cursor_cell is active (CLI agent rich input is open).
|
|
if cursor_visible && !self.grid_render_params.hide_cursor_cell {
|
|
grid_renderer::render_cursor(
|
|
&self.grid_render_params,
|
|
grid.cursor_render_point(),
|
|
grid.is_cursor_on_wide_char(),
|
|
model.alt_screen().cursor_style(),
|
|
padding_x,
|
|
adjusted_grid_origin,
|
|
self.grid_render_params.warp_theme.cursor().into(),
|
|
ctx,
|
|
self.terminal_view_id,
|
|
self.cursor_hint_text.as_mut(),
|
|
app,
|
|
);
|
|
}
|
|
|
|
record_trace_event!("alt_screen_element:paint:cursor_rendered");
|
|
|
|
self.render_selections(
|
|
&self.grid_render_params.size_info,
|
|
adjusted_grid_origin,
|
|
ctx,
|
|
);
|
|
self.render_participant_selections(
|
|
&self.grid_render_params.size_info,
|
|
adjusted_grid_origin,
|
|
ctx,
|
|
app,
|
|
);
|
|
|
|
if let Some(cli_subagent_view) = &mut self.cli_subagent_view {
|
|
ctx.scene.start_layer(ClipBounds::ActiveLayer);
|
|
let size = cli_subagent_view
|
|
.size()
|
|
.expect("Subagent output was laid out already.");
|
|
cli_subagent_view.paint(
|
|
vec2f(
|
|
self.bounds.expect("bounds set during paint.").max_x()
|
|
- CLI_SUBAGENT_HORIZONTAL_MARGIN
|
|
- size.x(),
|
|
self.bounds.expect("bounds set during paint.").max_y()
|
|
- CLI_SUBAGENT_VERTICAL_MARGIN
|
|
- size.y(),
|
|
),
|
|
ctx,
|
|
app,
|
|
);
|
|
ctx.scene.stop_layer();
|
|
}
|
|
|
|
record_trace_event!("alt_screen_element:paint:selection_rendered");
|
|
end_trace!();
|
|
}
|
|
|
|
fn size(&self) -> Option<Vector2F> {
|
|
self.size
|
|
}
|
|
|
|
fn dispatch_event(
|
|
&mut self,
|
|
event: &DispatchedEvent,
|
|
ctx: &mut EventContext,
|
|
app: &AppContext,
|
|
) -> bool {
|
|
if let Some(cli_subagent_view) = &mut self.cli_subagent_view {
|
|
if cli_subagent_view.dispatch_event(event, ctx, app) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
let bounds = self
|
|
.bounds
|
|
.expect("Bounds should be set before event dispatching");
|
|
let in_bounds = event.raw_event().in_bounds(bounds);
|
|
let vertical_scroll_pixels = self.vertical_scroll_pixels();
|
|
// Helper function to convert a global (window-space) position to a
|
|
// local (element-space) one.
|
|
let to_local = |position| position - bounds.origin() + vertical_scroll_pixels;
|
|
|
|
let z_index = self.z_index().expect("Z-index should exist.");
|
|
let Some(event_at_z_index) = event.at_z_index(z_index, ctx) else {
|
|
// Only proceed if there's a relevant event at this z-index.
|
|
return false;
|
|
};
|
|
|
|
match event_at_z_index {
|
|
Event::KeyDown {
|
|
keystroke,
|
|
chars,
|
|
details,
|
|
is_composing,
|
|
} => {
|
|
if !self.is_terminal_focused
|
|
|| (*is_composing && !handle_keystroke_despite_composing(keystroke))
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(escape_sequence) = (KeystrokeWithDetails {
|
|
keystroke,
|
|
key_without_modifiers: details.key_without_modifiers.as_deref(),
|
|
chars: Some(chars.as_str()),
|
|
})
|
|
.to_escape_sequence(self.model.lock().deref())
|
|
{
|
|
ctx.dispatch_typed_action(TerminalAction::ControlSequence(escape_sequence));
|
|
return true;
|
|
}
|
|
self.key_down(chars, ctx)
|
|
}
|
|
Event::ScrollWheel {
|
|
position,
|
|
delta,
|
|
precise,
|
|
modifiers: ModifiersState { ctrl: false, .. },
|
|
} if in_bounds => self.on_scroll(to_local(*position), *delta, *precise, ctx, app),
|
|
Event::LeftMouseDown {
|
|
position,
|
|
click_count,
|
|
modifiers,
|
|
..
|
|
} if in_bounds => self.left_mouse_down(
|
|
MouseState::new(MouseButton::Left, MouseAction::Pressed, *modifiers),
|
|
to_local(*position),
|
|
*click_count,
|
|
ctx,
|
|
app,
|
|
),
|
|
Event::RightMouseDown {
|
|
position,
|
|
cmd,
|
|
shift,
|
|
..
|
|
} if in_bounds => self.right_mouse_down(
|
|
MouseState::new(
|
|
MouseButton::Right,
|
|
MouseAction::Pressed,
|
|
ModifiersState {
|
|
alt: false,
|
|
cmd: *cmd,
|
|
shift: *shift,
|
|
ctrl: false,
|
|
func: false,
|
|
},
|
|
),
|
|
to_local(*position),
|
|
ctx,
|
|
app,
|
|
),
|
|
Event::LeftMouseUp {
|
|
position,
|
|
modifiers,
|
|
..
|
|
} if in_bounds => self.mouse_up(
|
|
MouseState::new(MouseButton::Left, MouseAction::Released, *modifiers),
|
|
to_local(*position),
|
|
ctx,
|
|
app,
|
|
),
|
|
Event::LeftMouseDragged {
|
|
position,
|
|
modifiers,
|
|
..
|
|
} if in_bounds => self.mouse_dragged(
|
|
MouseState::new(MouseButton::LeftDrag, MouseAction::Pressed, *modifiers),
|
|
to_local(*position),
|
|
ctx,
|
|
app,
|
|
),
|
|
Event::MouseMoved {
|
|
position,
|
|
is_synthetic,
|
|
..
|
|
} => {
|
|
if in_bounds {
|
|
self.mouse_moved(to_local(*position), *is_synthetic, app, ctx)
|
|
} else {
|
|
self.mouse_out(ctx)
|
|
}
|
|
}
|
|
Event::MiddleMouseDown { position, .. } if in_bounds => {
|
|
self.middle_mouse_down(to_local(*position), ctx)
|
|
}
|
|
Event::TypedCharacters { chars } => self.typed_characters(chars, ctx),
|
|
Event::DragAndDropFiles { paths, .. } if in_bounds => {
|
|
self.drag_and_drop_file(paths, ctx)
|
|
}
|
|
Event::SetMarkedText {
|
|
marked_text,
|
|
selected_range,
|
|
} => self.set_marked_text(marked_text, selected_range, ctx),
|
|
Event::ClearMarkedText => self.clear_marked_text(ctx),
|
|
Event::ModifierKeyChanged { key_code, state } => {
|
|
if self.is_terminal_focused {
|
|
let is_press = matches!(state, KeyState::Pressed);
|
|
if let Some(escape_sequence) = maybe_kitty_keyboard_escape_sequence(
|
|
self.model.lock().deref(),
|
|
key_code,
|
|
is_press,
|
|
) {
|
|
ctx.dispatch_typed_action(TerminalAction::ControlSequence(escape_sequence));
|
|
return true;
|
|
}
|
|
self.maybe_handle_voice_toggle(key_code, state, ctx)
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn origin(&self) -> Option<UiPoint> {
|
|
self.origin
|
|
}
|
|
}
|
|
|
|
impl NewScrollableElement for AltScreenElement {
|
|
fn axis(&self) -> ScrollableAxis {
|
|
ScrollableAxis::Vertical
|
|
}
|
|
|
|
fn scroll_data(&self, _axis: Axis, app: &AppContext) -> Option<ScrollData> {
|
|
ScrollableElement::scroll_data(self, app)
|
|
}
|
|
|
|
fn scroll(&mut self, delta: Pixels, _axis: Axis, ctx: &mut EventContext) {
|
|
ScrollableElement::scroll(self, delta, ctx)
|
|
}
|
|
|
|
fn axis_should_handle_scroll_wheel(&self, axis: Axis) -> bool {
|
|
matches!(axis, Axis::Horizontal)
|
|
}
|
|
}
|
|
|
|
impl ScrollableElement for AltScreenElement {
|
|
fn scroll_data(&self, _app: &AppContext) -> Option<ScrollData> {
|
|
let line_height = self.line_height();
|
|
let visible_lines = self.visible_lines.expect("should be set after layout");
|
|
let total_lines = self.total_lines();
|
|
// If the number of visible_lines is within a rounding error of total
|
|
// lines, just set them to be exactly equal so the scrollable element
|
|
// knows not to render a scrollbar in that case. Otherwise, we risk
|
|
// seeing spurious scrollbars because of our issues with f32 rounding
|
|
// errors.
|
|
let visible_px = if heights_approx_eq(visible_lines, total_lines) {
|
|
total_lines.to_pixels(line_height)
|
|
} else {
|
|
visible_lines.to_pixels(line_height)
|
|
};
|
|
Some(ScrollData {
|
|
scroll_start: self.scroll_top.to_pixels(line_height),
|
|
visible_px,
|
|
total_size: total_lines.to_pixels(line_height),
|
|
})
|
|
}
|
|
|
|
fn scroll(&mut self, delta: Pixels, ctx: &mut EventContext) {
|
|
self.scroll_top = (self.scroll_top - delta.to_lines(self.line_height()))
|
|
.max(Lines::zero())
|
|
.min(self.max_scroll_top.unwrap());
|
|
ctx.dispatch_typed_action(TerminalAction::SharedSessionViewerAltScroll {
|
|
new_scroll_top: self.scroll_top,
|
|
});
|
|
}
|
|
}
|