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 {