From 581f2b623d96dca04b6cf82cdada728ff4e09ff1 Mon Sep 17 00:00:00 2001 From: cartermp Date: Wed, 25 Mar 2026 11:38:06 -0700 Subject: [PATCH] install improvement and copy paste --- assets/icon.svg | 20 +++++ install.sh | 44 ++++++++++ src/main.rs | 224 ++++++++++++++++++++++++++++++++++++++++++------ src/renderer.rs | 15 +++- 4 files changed, 278 insertions(+), 25 deletions(-) create mode 100644 assets/icon.svg diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..19ae262 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/install.sh b/install.sh index 3d43a00..d9d946a 100755 --- a/install.sh +++ b/install.sh @@ -19,6 +19,49 @@ mkdir -p "$APP_BUNDLE/Contents/Resources" cp "$BIN_DIR/term" "$APP_BUNDLE/Contents/MacOS/term" cp "$BIN_DIR/tcat" "$APP_BUNDLE/Contents/MacOS/tcat" +# Build .icns from assets/icon.svg using macOS-native NSImage (no external tools needed) +ICON_SVG="$REPO_DIR/assets/icon.svg" +ICON_ICNS="$APP_BUNDLE/Contents/Resources/AppIcon.icns" +if [ -f "$ICON_SVG" ]; then + echo "→ Generating AppIcon.icns from assets/icon.svg" + ICONSET=$(mktemp -d) + ICONSET_DIR="$ICONSET/AppIcon.iconset" + mkdir -p "$ICONSET_DIR" + + # Render SVG to PNG at a given size using macOS NSImage — no brew deps required. + render_png() { + local size=$1 out=$2 + osascript - "$ICON_SVG" "$out" "$size" <<'APPLESCRIPT' +on run {svgPath, pngPath, sizeStr} + set sz to sizeStr as integer + set img to current application's NSImage's alloc()'s initWithContentsOfFile:svgPath + img's setSize:{sz, sz} + set rep to current application's NSBitmapImageRep's imageRepWithData:(img's TIFFRepresentation()) + set dat to rep's representationUsingType:(current application's NSBitmapImageFileTypePNG) |properties|:{|} + dat's writeToFile:pngPath atomically:true +end run +APPLESCRIPT + } + + render_png 16 "$ICONSET_DIR/icon_16x16.png" + render_png 32 "$ICONSET_DIR/icon_16x16@2x.png" + render_png 32 "$ICONSET_DIR/icon_32x32.png" + render_png 64 "$ICONSET_DIR/icon_32x32@2x.png" + render_png 128 "$ICONSET_DIR/icon_128x128.png" + render_png 256 "$ICONSET_DIR/icon_128x128@2x.png" + render_png 256 "$ICONSET_DIR/icon_256x256.png" + render_png 512 "$ICONSET_DIR/icon_256x256@2x.png" + render_png 512 "$ICONSET_DIR/icon_512x512.png" + render_png 1024 "$ICONSET_DIR/icon_512x512@2x.png" + + if iconutil -c icns "$ICONSET_DIR" -o "$ICON_ICNS" 2>/dev/null; then + echo " ✓ AppIcon.icns written" + else + echo " ⚠ iconutil failed — icon skipped" + fi + rm -rf "$ICONSET" +fi + # Info.plist — minimum viable for macOS to treat this as an app cat > "$APP_BUNDLE/Contents/Info.plist" <<'PLIST' @@ -32,6 +75,7 @@ cat > "$APP_BUNDLE/Contents/Info.plist" <<'PLIST' CFBundleVersion 1.0 CFBundleExecutable term CFBundlePackageType APPL + CFBundleIconFile AppIcon NSHighResolutionCapable LSUIElement diff --git a/src/main.rs b/src/main.rs index ee2b1cc..825dc25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,27 @@ enum AppEvent { PtyExit { tab_id: usize }, } +// ── Selection ───────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy)] +struct Selection { + start: (usize, usize), // (row, col) in viewport coordinates + end: (usize, usize), +} + +impl Selection { + /// Returns (r0, c0, r1, c1) with r0≤r1 (and c0≤c1 when r0==r1). + fn normalized(self) -> (usize, usize, usize, usize) { + let (r0, c0) = self.start; + let (r1, c1) = self.end; + if r0 < r1 || (r0 == r1 && c0 <= c1) { + (r0, c0, r1, c1) + } else { + (r1, c1, r0, c0) + } + } +} + // ── Tab ─────────────────────────────────────────────────────────────────────── struct Tab { @@ -81,6 +102,11 @@ struct App { modifiers: ModifiersState, cursor_pos: (f64, f64), + // Selection + sel_anchor: Option<(usize, usize)>, + selection: Option, + selecting: bool, + // Cursor blink cursor_visible: bool, last_blink: Instant, @@ -102,6 +128,9 @@ impl App { proxy, modifiers: ModifiersState::empty(), cursor_pos: (-1.0, -1.0), + sel_anchor: None, + selection: None, + selecting: false, cursor_visible: true, last_blink: Instant::now(), engine: Engine::new(), @@ -209,7 +238,51 @@ impl App { fn switch_tab(&mut self, idx: usize) { if idx < self.tabs.len() { self.active_tab = idx; + self.selection = None; + } + } + + /// Convert a pixel coordinate to a terminal (row, col), or None if in the tab bar. + fn pixel_to_cell(&self, mx: f64, my: f64) -> Option<(usize, usize)> { + let r = self.renderer.as_ref()?; + let tby = r.tab_bar_height; + if mx < 0.0 || my < tby as f64 { + return None; } + let (cols, rows) = self.term_size(); + let row = ((my as usize).saturating_sub(tby)) / r.cell_height; + let col = mx as usize / r.cell_width; + Some((row.min(rows.saturating_sub(1)), col.min(cols.saturating_sub(1)))) + } + + /// Extract the text covered by the current selection. + fn selection_text(&self) -> String { + let sel = match self.selection { + Some(s) => s, + None => return String::new(), + }; + let state = &self.active().terminal.state; + let (r0, c0, r1, c1) = sel.normalized(); + let mut result = String::new(); + for row in r0..=r1 { + let col_start = if row == r0 { c0 } else { 0 }; + let col_end = if row == r1 { c1 + 1 } else { state.cols }; + let mut line = String::new(); + for col in col_start..col_end.min(state.cols) { + let cell = state.visual_cell(row, col); + line.push(if cell.c == '\0' { ' ' } else { cell.c }); + } + if row > r0 { + result.push('\n'); + } + // Trim trailing spaces on wrapped lines, keep them on the last line + if row < r1 { + result.push_str(line.trim_end()); + } else { + result.push_str(line.trim_end()); + } + } + result } fn prev_tab(&mut self) { @@ -261,6 +334,30 @@ impl App { let alt = self.modifiers.alt_key(); let sup = self.modifiers.super_key(); + // Clear selection on any keypress, except Cmd+C (which copies it) and + // bare modifier key presses (Cmd/Shift/Alt/Ctrl alone must not clear the + // selection before the chord is complete). + let is_cmd_c = sup + && matches!(&event.logical_key, Key::Character(c) if c.as_str() == "c"); + let is_modifier_only = matches!( + &event.logical_key, + Key::Named( + NamedKey::Shift + | NamedKey::Control + | NamedKey::Alt + | NamedKey::Super + | NamedKey::Hyper + | NamedKey::Meta + | NamedKey::CapsLock + ) + ); + if !is_cmd_c && !is_modifier_only && self.selection.is_some() { + self.selection = None; + if let Some(w) = &self.window { + w.request_redraw(); + } + } + // ── Cmd chords ──────────────────────────────────────────────────────── if sup { match &event.logical_key { @@ -288,6 +385,25 @@ impl App { self.pty_write(b"\x15"); } Key::Character(c) => match c.as_str() { + "c" => { + // Copy selection if active; otherwise do nothing. + if self.selection.is_some() { + let text = self.selection_text(); + if !text.is_empty() { + copy_to_clipboard(&text); + } + self.selection = None; + if let Some(w) = &self.window { + w.request_redraw(); + } + } + } + "v" => { + let text = paste_from_clipboard(); + if !text.is_empty() { + self.pty_write(text.as_bytes()); + } + } "t" => { self.open_tab(); self.sync_window_title(); @@ -462,6 +578,7 @@ impl App { let state_ptr: *const _ = &self.tabs[ai].terminal.state; let ghost_owned: Option = self.tabs[ai].ghost_text.clone(); let show_cur = self.cursor_visible; + let sel = self.selection.map(|s| s.normalized()); let surface = match &mut self.surface { Some(s) => s, @@ -491,6 +608,7 @@ impl App { &tab_titles, ai, hover, + sel, ); buf.present().unwrap(); } @@ -592,16 +710,29 @@ impl ApplicationHandler for App { WindowEvent::CursorMoved { position, .. } => { self.cursor_pos = (position.x, position.y); - // Redraw only when in tab bar so hover highlights update let in_bar = self .renderer .as_ref() .map(|r| position.y < r.tab_bar_height as f64) .unwrap_or(false); - if in_bar - && let Some(w) = &self.window { + if self.selecting { + if let (Some(anchor), Some(cell)) = + (self.sel_anchor, self.pixel_to_cell(position.x, position.y)) + { + self.selection = if cell != anchor { + Some(Selection { start: anchor, end: cell }) + } else { + None + }; + } + if let Some(w) = &self.window { + w.request_redraw(); + } + } else if in_bar { + if let Some(w) = &self.window { w.request_redraw(); } + } } WindowEvent::CursorLeft { .. } => { @@ -618,30 +749,54 @@ impl ApplicationHandler for App { } => { let (mx, my) = self.cursor_pos; if let Some(r) = &self.renderer - && my >= 0.0 && my < r.tab_bar_height as f64 { - let bw = self - .window - .as_ref() - .map(|w| w.inner_size().width as usize) - .unwrap_or(0); - let tabs_w = bw.saturating_sub(r.tab_bar_height); - let n = self.tabs.len().max(1); - let tab_w = tabs_w / n; - if mx as usize >= tabs_w { - // + button - self.open_tab(); + && my >= 0.0 && my < r.tab_bar_height as f64 + { + // Tab bar click + let bw = self + .window + .as_ref() + .map(|w| w.inner_size().width as usize) + .unwrap_or(0); + let tabs_w = bw.saturating_sub(r.tab_bar_height); + let n = self.tabs.len().max(1); + let tab_w = tabs_w / n; + if mx as usize >= tabs_w { + self.open_tab(); + self.sync_window_title(); + } else { + let idx = (mx as usize) / tab_w; + if idx < self.tabs.len() { + self.switch_tab(idx); self.sync_window_title(); - } else { - let idx = (mx as usize) / tab_w; - if idx < self.tabs.len() { - self.switch_tab(idx); - self.sync_window_title(); - } - } - if let Some(w) = &self.window { - w.request_redraw(); } } + if let Some(w) = &self.window { + w.request_redraw(); + } + } else { + // Terminal area: start a new selection drag + self.selection = None; + self.sel_anchor = self.pixel_to_cell(mx, my); + self.selecting = true; + if let Some(w) = &self.window { + w.request_redraw(); + } + } + } + + WindowEvent::MouseInput { + state: ElementState::Released, + button: winit::event::MouseButton::Left, + .. + } => { + self.selecting = false; + // A click without drag leaves no selection + if self.selection.is_none() { + self.sel_anchor = None; + } + if let Some(w) = &self.window { + w.request_redraw(); + } } WindowEvent::MouseWheel { delta, .. } => { @@ -719,6 +874,27 @@ impl ApplicationHandler for App { } } +// ── Clipboard ───────────────────────────────────────────────────────────────── + +fn copy_to_clipboard(text: &str) { + use std::io::Write; + use std::process::{Command, Stdio}; + if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() { + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } +} + +fn paste_from_clipboard() -> String { + use std::process::Command; + Command::new("pbpaste") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() +} + // ── Shell environment setup ─────────────────────────────────────────────────── fn setup_shell_env(cmd: &mut CommandBuilder) { diff --git a/src/renderer.rs b/src/renderer.rs index 4a1fa2e..116834b 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -360,6 +360,7 @@ impl Renderer { // ── Public render ───────────────────────────────────────────────────────── /// `hover` — hovered tab index, or `tabs.len()` for the + button. + /// `selection` — normalized (r0, c0, r1, c1) in viewport coordinates, if any. pub fn render( &mut self, buf: &mut [u32], @@ -371,6 +372,7 @@ impl Renderer { tabs: &[String], active_tab: usize, hover: Option, + selection: Option<(usize, usize, usize, usize)>, ) { buf.fill(DEFAULT_BG.to_u32()); @@ -385,6 +387,8 @@ impl Renderer { let vis_cols = (bw / cw).min(state.cols); // ── 1. Terminal grid ────────────────────────────────────────────────── + let sel_bg = Color::new(0x26, 0x4a, 0x7a).to_u32(); // muted blue selection + for row in 0..vis_rows { for col in 0..vis_cols { let cell = state.visual_cell(row, col); @@ -394,9 +398,18 @@ impl Renderer { std::mem::swap(&mut fg, &mut bg); } + let selected = if let Some((r0, c0, r1, c1)) = selection { + row >= r0 + && row <= r1 + && !(row == r0 && col < c0) + && !(row == r1 && col > c1) + } else { + false + }; + let px = col * cw; let py = tby + row * ch; - let bg32 = bg.to_u32(); + let bg32 = if selected { sel_bg } else { bg.to_u32() }; for dy in 0..ch { let y = py + dy; if y >= bh { -- 2.51.2