Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 8 additions & 16 deletions crates/zedra-terminal/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use alacritty_terminal::vte::ansi::{Color as AlacColor, CursorShape, NamedColor}
use gpui::*;
use itertools::Itertools;

use crate::MONO_FONT_FAMILY;
use crate::input::TerminalInputHandler;
use crate::selection::TerminalSelectionDocument;
use crate::terminal::*;
Expand Down Expand Up @@ -189,6 +188,7 @@ pub struct TerminalElement {
focus_handle: FocusHandle,
focused: bool,
selection_active: bool,
metric_revision: u64,
}

impl TerminalElement {
Expand All @@ -204,6 +204,7 @@ impl TerminalElement {
focus_handle: FocusHandle,
focused: bool,
selection_active: bool,
metric_revision: u64,
) -> Self {
Self {
content,
Expand All @@ -217,6 +218,7 @@ impl TerminalElement {
focus_handle,
focused,
selection_active,
metric_revision,
}
}

Expand Down Expand Up @@ -596,20 +598,7 @@ impl Element for TerminalElement {
// Surface identity for native-selection occlusion checks; Normal
// behavior so it never blocks other elements.
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
// Use JetBrains Mono NL - embedded monospace font (loaded once at app init)
let font = Font {
family: MONO_FONT_FAMILY.into(),
features: FontFeatures::default(),
fallbacks: Some(FontFallbacks::from_fonts(vec![
"Noto Sans Symbols 2".to_string(),
"Apple Symbols".to_string(),
"Menlo".to_string(),
"Droid Sans Mono".to_string(),
"monospace".to_string(),
])),
weight: FontWeight::NORMAL,
style: FontStyle::Normal,
};
let font = crate::terminal_font();

// Use configured line height
let line_height = self.size.line_height;
Expand Down Expand Up @@ -770,9 +759,12 @@ impl Element for TerminalElement {
if needs_reconcile {
let view = self.view.clone();
let actual_bounds = bounds.size;
let revision = self.metric_revision;
window.defer(cx, move |_window, cx| {
let _ = view.update(cx, |view, cx| {
view.reconcile_bounds_fallback(actual_bounds, cell_width, line_height, cx);
if view.metric_revision() == revision {
view.reconcile_bounds_fallback(actual_bounds, cell_width, line_height, cx);
}
});
});
}
Expand Down
19 changes: 19 additions & 0 deletions crates/zedra-terminal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod selection;
pub mod terminal;
pub mod theme;
pub mod view;
pub mod zoom_ratchet;

pub use element::{TerminalElement, TerminalElementLayout};
pub use input::*;
Expand All @@ -14,12 +15,30 @@ pub use keys::*;
pub use terminal::*;
pub use theme::{AnsiPalette, TerminalTheme};
pub use view::*;
pub use zoom_ratchet::{TerminalFontSize, TerminalZoomRatchet, ZoomStep};

use gpui::*;

/// The font family name for the embedded terminal font.
/// The font bytes and loader live in the `zedra` crate (`fonts` module).
pub const MONO_FONT_FAMILY: &str = "JetBrainsMonoNL Nerd Font Mono";

/// Shared terminal font descriptor used by both grid measurement and paint.
pub fn terminal_font() -> gpui::Font {
gpui::Font {
family: MONO_FONT_FAMILY.into(),
features: gpui::FontFeatures::default(),
fallbacks: Some(gpui::FontFallbacks::from_fonts(vec![
"Noto Sans Symbols 2".to_string(),
"Apple Symbols".to_string(),
"Menlo".to_string(),
"Droid Sans Mono".to_string(),
"monospace".to_string(),
])),
weight: gpui::FontWeight::NORMAL,
style: gpui::FontStyle::Normal,
}
}

/// The font size for the embedded terminal font.
pub const TERMINAL_FONT_SIZE: Pixels = px(12.0);
149 changes: 130 additions & 19 deletions crates/zedra-terminal/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub enum TerminalEvent {
cols: u16,
rows: u16,
},
InteractionStarted,
UserOutboundInput,
TitleChanged(Option<String>),
OscEvent(OscEvent),
OpenHyperlink(TerminalHyperlink),
Expand All @@ -47,6 +49,14 @@ pub enum TerminalEvent {
NativePasteMenuRequested {
position: GpuiPoint<Pixels>,
},
/// Haptic feedback for one accepted pinch zoom step. The app maps this to
/// the platform bridge; the terminal crate never touches platform APIs.
PinchStepHaptic,
/// A pinch gesture settled (ended or cancelled) with a changed font size.
/// Emitted once per gesture; the app persists the size and forwards it.
PinchSettled {
font_size: u8,
},
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -315,16 +325,20 @@ impl Terminal {
self.input_tx.clone()
}

pub async fn send_bytes(&mut self, bytes: Vec<u8>) {
if let Some(tx) = &self.input_tx {
if let Err(e) = tx.send(bytes).await {
error!("failed to send input: {:?}", e)
}
pub async fn send_bytes(&mut self, bytes: Vec<u8>) -> bool {
let Some(tx) = &self.input_tx else {
return false;
};
if let Err(e) = tx.send(bytes).await {
error!("failed to send input: {:?}", e);
return false;
}
self.send_terminal_event(TerminalEvent::UserOutboundInput);
true
}

pub async fn send_input(&mut self, text: String) {
self.send_bytes(text.into_bytes()).await;
pub async fn send_input(&mut self, text: String) -> bool {
self.send_bytes(text.into_bytes()).await
}

/// Feed bytes from PTY output buffer into the terminal emulator.
Expand Down Expand Up @@ -419,7 +433,7 @@ impl Terminal {
self.send_terminal_event(TerminalEvent::TitleChanged(None));
}
AlacTermEvent::PtyWrite(text) => {
self.send_bytes_sync(text.into_bytes());
self.send_protocol_reply(text.into_bytes());
}
AlacTermEvent::TextAreaSizeRequest(format) => {
let window_size = alacritty_terminal::event::WindowSize {
Expand All @@ -428,7 +442,7 @@ impl Terminal {
cell_width: (self.size.cell_width / px(1.0)) as u16,
cell_height: (self.size.line_height / px(1.0)) as u16,
};
self.send_bytes_sync(format(window_size).into_bytes());
self.send_protocol_reply(format(window_size).into_bytes());
}
AlacTermEvent::ColorRequest(_index, _format) => {
// The host answers OSC 10/11/12 color queries inline at the PTY boundary
Expand All @@ -441,12 +455,25 @@ impl Terminal {
}
}

fn send_protocol_reply(&self, bytes: Vec<u8>) {
let Some(tx) = &self.input_tx else {
return;
};
if let Err(e) = tx.try_send(bytes) {
error!("failed to send bytes: {:?}", e);
}
}

fn send_terminal_event(&self, event: TerminalEvent) {
if let Err(e) = self.event_tx.send(event) {
error!("failed to send terminal event: {:?}", e);
}
}

pub fn outbound_input_sender(&self) -> tokio::sync::broadcast::Sender<TerminalEvent> {
self.event_tx.clone()
}

/// Feed bytes from PTY output buffer into the OSC scanner
/// and emit events to the event channel.
pub fn feed_osc_bytes(&mut self, bytes: &[u8]) {
Expand Down Expand Up @@ -691,15 +718,19 @@ impl Terminal {
Some(Point::new(Line(line), Column(column)))
}

pub fn send_bytes_sync(&self, bytes: Vec<u8>) {
if let Some(tx) = &self.input_tx {
if let Err(e) = tx.try_send(bytes) {
error!("failed to send bytes: {:?}", e);
}
pub fn send_bytes_sync(&self, bytes: Vec<u8>) -> bool {
let Some(tx) = &self.input_tx else {
return false;
};
if let Err(e) = tx.try_send(bytes) {
error!("failed to send bytes: {:?}", e);
return false;
}
self.send_terminal_event(TerminalEvent::UserOutboundInput);
true
}

pub fn paste_text(&self, text: &str) {
pub fn paste_text(&self, text: &str) -> bool {
let bracketed = self.mode.contains(TermMode::BRACKETED_PASTE);
let bytes = if bracketed {
let text = text.replace('\x1b', "");
Expand All @@ -708,11 +739,15 @@ impl Terminal {
text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
};

if let Some(tx) = &self.input_tx {
if let Err(error) = tx.try_send(bytes) {
error!("failed to send pasted text: {:?}", error);
}
let Some(tx) = &self.input_tx else {
return false;
};
if let Err(error) = tx.try_send(bytes) {
error!("failed to send pasted text: {:?}", error);
return false;
}
self.send_terminal_event(TerminalEvent::UserOutboundInput);
true
}

pub fn selection_range(&self) -> Option<Range<usize>> {
Expand Down Expand Up @@ -4903,4 +4938,80 @@ mod tests {
key_char: Some(key.to_string()),
}
}

#[test]
fn user_keystroke_emits_outbound_input_signal() {
use tokio::sync::mpsc;
let (input_tx, mut input_rx) = mpsc::channel(4);
let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0));
terminal.input_tx = Some(input_tx);
let mut events = terminal.subscribe_events();
terminal.handle_keystroke(&plain_keystroke("a"));
let sent = input_rx.try_recv().expect("keystroke enqueues bytes");
assert!(!sent.is_empty());
let event = events.try_recv().expect("keystroke signals outbound input");
assert!(matches!(event, TerminalEvent::UserOutboundInput));
}

#[test]
fn user_paste_emits_outbound_input_signal() {
use tokio::sync::mpsc;
let (input_tx, mut input_rx) = mpsc::channel(4);
let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0));
terminal.input_tx = Some(input_tx);
let mut events = terminal.subscribe_events();
assert!(terminal.paste_text("hello"));
let sent = input_rx.try_recv().expect("paste enqueues bytes");
assert!(!sent.is_empty());
let event = events.try_recv().expect("paste signals outbound input");
assert!(matches!(event, TerminalEvent::UserOutboundInput));
}

#[test]
fn protocol_reply_does_not_emit_outbound_input_signal() {
use tokio::sync::mpsc;
let (input_tx, mut input_rx) = mpsc::channel(4);
let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0));
terminal.input_tx = Some(input_tx);
let mut events = terminal.subscribe_events();
terminal.send_protocol_reply(b"reply".to_vec());
let sent = input_rx.try_recv().expect("protocol reply enqueues bytes");
assert!(!sent.is_empty());
assert!(
events.try_recv().is_err(),
"protocol replies must not trigger post-input reclaim"
);
}

#[test]
fn user_async_send_bytes_emits_outbound_input_signal() {
use tokio::sync::mpsc;
futures::executor::block_on(async {
let (input_tx, mut input_rx) = mpsc::channel(4);
let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0));
terminal.input_tx = Some(input_tx);
let mut events = terminal.subscribe_events();
assert!(terminal.send_bytes(b"test".to_vec()).await);
let sent = input_rx.recv().await.expect("bytes sent");
assert_eq!(sent, b"test");
let event = events.try_recv().expect("outbound signal emitted");
assert!(matches!(event, TerminalEvent::UserOutboundInput));
});
}

#[test]
fn user_async_send_input_emits_outbound_input_signal() {
use tokio::sync::mpsc;
futures::executor::block_on(async {
let (input_tx, mut input_rx) = mpsc::channel(4);
let mut terminal = Terminal::new(80, 4, px(10.0), px(20.0));
terminal.input_tx = Some(input_tx);
let mut events = terminal.subscribe_events();
assert!(terminal.send_input("text".to_string()).await);
let sent = input_rx.recv().await.expect("bytes sent");
assert_eq!(sent, b"text");
let event = events.try_recv().expect("outbound signal emitted");
assert!(matches!(event, TerminalEvent::UserOutboundInput));
});
}
}
Loading