import subprocess import os from fabric.widgets.box import Box from fabric.widgets.label import Label from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.utils import idle_add, remove_handler, exec_shell_command_async from gi.repository import GLib, Gdk, Gtk import utils.icons as icons import config.data as data class TmuxManager(Box): def __init__(self, **kwargs): super().__init__( name="tmux-manager", visible=False, all_visible=False, **kwargs, ) self.launcher = kwargs["launcher"] self.selected_index = -1 # Track the selected item index self._arranger_handler: int = 0 self.viewport = Box(name="viewport", spacing=4, orientation="v") self.session_name_entry = Entry( name="session-name-entry", placeholder="Create Tmux Session...", h_expand=True, on_activate=lambda entry, *_: self.create_session(entry.get_text()), on_key_press_event=self.on_entry_key_press, ) self.session_name_entry.props.xalign = 0.5 self.scrolled_window = ScrolledWindow( name="scrolled-window", spacing=10, min_content_size=(450, 105), max_content_size=(450, 105), child=self.viewport, ) self.header_box = Box( name="header_box", spacing=10, orientation="h", children=[ Button( name="new-session-button", child=Label(name="new-session-label", markup=icons.add), tooltip_text="Create New Session", on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()), ), self.session_name_entry, ], ) self.tmux_box = Box( name="tmux-box", spacing=10, h_expand=True, orientation="v", children=[ self.header_box, self.scrolled_window, ], ) self.add(self.tmux_box) self.show_all() def close_manager(self): """Close the tmux manager""" self.viewport.children = [] self.selected_index = -1 # Reset selection self.launcher.close() def open_manager(self): """Open the tmux manager and refresh sessions""" self.viewport.set_visible(True) self.refresh_sessions() self.session_name_entry.set_text("") self.session_name_entry.grab_focus() def refresh_sessions(self): """Get tmux sessions and populate the viewport""" remove_handler(self._arranger_handler) if self._arranger_handler else None self.viewport.children = [] self.selected_index = -1 # Clear selection when viewport changes # Get tmux sessions sessions = self.get_tmux_sessions() if not sessions: # Create a container box to better center the message container = Box( name="no-tmux-container", orientation="v", h_align="center", v_align="center", h_expand=True, v_expand=True ) # Show a message if no sessions label = Label( name="no-tmux", markup=icons.terminal, h_align="center", v_align="center", ) container.add(label) self.viewport.add(container) return # Add session slots to viewport for session in sessions: self.viewport.add(self.create_session_slot(session)) def get_tmux_sessions(self): """Get list of tmux sessions""" try: result = subprocess.run( ["tmux", "list-sessions", "-F", "#{session_name}"], capture_output=True, text=True ) if result.returncode == 0: return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()] return [] except Exception as e: print(f"Error getting tmux sessions: {e}") return [] def create_session_slot(self, session_name): """Create a button for a tmux session""" # Create an entry for inline editing (initially hidden) name_entry = Entry( name="session-name-entry", text=session_name, visible=False, on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry), on_key_press_event=self.on_rename_key_press, ) # Create the label showing the session name name_label = Label( name="app-label", label=session_name, ellipsization="end", v_align="center", h_align="center", ) # Session slot content box slot_box = Box( name="slot-box", orientation="h", spacing=10, children=[ Label( name="tmux-icon", markup=icons.terminal, # Use existing terminal icon ), name_label, name_entry, ], ) button = Button( name="slot-button", # reuse existing CSS styling child=slot_box, tooltip_text=f"Attach to session: {session_name}", on_clicked=lambda *_: self.attach_to_session(session_name), can_focus=True, # Ensure the button can receive focus ) # Add double-click handler to start renaming button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry) # Add key press handler for 'r' to rename button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry) # Store reference to entry and label in button for later access button.name_entry = name_entry button.name_label = name_label button.session_name = session_name return button def on_session_click(self, button, event, session_name, label, entry): """Handle clicks on session buttons""" # Handle double-click to rename if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1: self.start_rename(button, session_name, label, entry) return True # Handle right click for context menu elif event.button == 3: menu = Gtk.Menu() # Rename option rename_item = Gtk.MenuItem(label="Rename") rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry)) menu.append(rename_item) # Kill option kill_item = Gtk.MenuItem(label="Kill Session") kill_item.connect("activate", lambda _: self.kill_session(session_name)) menu.append(kill_item) menu.show_all() menu.popup_at_pointer(event) return True return False def start_rename(self, button, session_name, label, entry): """Start inline renaming of a session""" # Hide label, show entry label.set_visible(False) entry.set_visible(True) # Focus entry and select all text entry.grab_focus() entry.select_region(0, -1) # Mark button as being edited button.get_style_context().add_class("editing") def finish_rename(self, button, old_name, entry): """Finish renaming a session""" new_name = entry.get_text().strip() # Only rename if the name changed and isn't empty if new_name and new_name != old_name: self.rename_session(old_name, new_name) # Reset UI state self.cancel_rename(button) def cancel_rename(self, button): """Cancel renaming operation""" # Restore original view button.name_entry.set_visible(False) button.name_label.set_visible(True) # Remove editing style button.get_style_context().remove_class("editing") # Return focus to session name entry self.session_name_entry.grab_focus() def on_rename_key_press(self, entry, event): """Handle key presses in the rename entry""" if event.keyval == Gdk.KEY_Escape: # Find the parent button parent = entry.get_parent() while parent and not isinstance(parent, Button): parent = parent.get_parent() if parent: self.cancel_rename(parent) return True return False def on_session_right_click(self, button, event, session_name): """Handle right-click on a session button to show context menu""" if event.button == 3: # Right click menu = Gtk.Menu() # Rename option rename_item = Gtk.MenuItem(label="Rename") rename_item.connect("activate", lambda _: self.start_rename( button, session_name, button.name_label, button.name_entry )) menu.append(rename_item) # Kill option kill_item = Gtk.MenuItem(label="Kill Session") kill_item.connect("activate", lambda _: self.kill_session(session_name)) menu.append(kill_item) menu.show_all() menu.popup_at_pointer(event) return True return False def on_entry_key_press(self, widget, event): """Handle key press events in the entry""" if event.keyval == Gdk.KEY_Escape: self.close_manager() return True # Custom navigation with UP/DOWN keys removed return False def scroll_to_selected(self, button): """Scroll to ensure the selected button is visible""" def scroll(): adj = self.scrolled_window.get_vadjustment() alloc = button.get_allocation() if alloc.height == 0: return False # Retry if allocation isn't ready y = alloc.y height = alloc.height page_size = adj.get_page_size() current_value = adj.get_value() # Calculate visible boundaries visible_top = current_value visible_bottom = current_value + page_size if y < visible_top: # Item above viewport - align to top adj.set_value(y) elif y + height > visible_bottom: # Item below viewport - align to bottom new_value = y + height - page_size adj.set_value(new_value) # No action if already fully visible return False GLib.idle_add(scroll) def create_session(self, session_name): """Create a new tmux session""" if not session_name: # Get existing session names existing_sessions = self.get_tmux_sessions() # Find the next available number counter = 0 while str(counter) in existing_sessions: counter += 1 session_name = str(counter) try: # Clean the session name (replace spaces with underscores) clean_name = session_name.strip().replace(" ", "_") # Create session subprocess.run( ["tmux", "new-session", "-d", "-s", clean_name], check=True ) # Refresh the session list self.refresh_sessions() # Clear entry self.session_name_entry.set_text("") # Launch a terminal and attach to this session terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}") exec_shell_command_async(terminal_cmd) # Close manager self.close_manager() except Exception as e: print(f"Error creating tmux session: {e}") def attach_to_session(self, session_name): """Attach to an existing tmux session""" try: # Launch a terminal and attach to this session terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}") exec_shell_command_async(terminal_cmd) self.close_manager() except Exception as e: print(f"Error attaching to tmux session: {e}") def get_terminal_command(self, cmd): """Get terminal command based on configured terminal or available terminals""" # First try to use the configured terminal command if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND: parts = data.TERMINAL_COMMAND.split() terminal = parts[0] try: # Check if the configured terminal is available subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) return f"{data.TERMINAL_COMMAND} {cmd}" except subprocess.CalledProcessError: # If configured terminal is not available, fall back to defaults pass # Fallback to checking available terminals terminals = [ ("kitty", f"kitty -e {cmd}"), ("alacritty", f"alacritty -e {cmd}"), ("foot", f"foot {cmd}"), ("gnome-terminal", f"gnome-terminal -- {cmd}"), ("konsole", f"konsole -e {cmd}"), ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), ] for term, term_cmd in terminals: try: # Check if terminal is available subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) return term_cmd except subprocess.CalledProcessError: continue # Default fallback return f"kitty -e {cmd}" def rename_session_dialog(self, old_name): """Show dialog to rename a session""" dialog = Gtk.Dialog( title="Rename Session", transient_for=None, flags=0 ) dialog.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK ) content_area = dialog.get_content_area() entry = Gtk.Entry() entry.set_text(old_name) entry.set_activates_default(True) content_area.add(entry) dialog.set_default_response(Gtk.ResponseType.OK) dialog.show_all() response = dialog.run() if response == Gtk.ResponseType.OK: new_name = entry.get_text() if new_name and new_name != old_name: self.rename_session(old_name, new_name) dialog.destroy() def rename_session(self, old_name, new_name): """Rename a tmux session""" try: # Clean the session name (replace spaces with underscores) clean_name = new_name.strip().replace(" ", "_") # Rename session subprocess.run( ["tmux", "rename-session", "-t", old_name, clean_name], check=True ) # Refresh the session list self.refresh_sessions() except Exception as e: print(f"Error renaming tmux session: {e}") def kill_session(self, session_name): """Kill a tmux session""" try: # Kill session subprocess.run( ["tmux", "kill-session", "-t", session_name], check=True ) # Refresh the session list self.refresh_sessions() # Close the launcher after killing session self.close_manager() except Exception as e: print(f"Error killing tmux session: {e}") # Add new method to handle key presses on session slots def on_slot_key_press(self, button, event, session_name, label, entry): """Handle key presses on session buttons""" # Print debugging info print(f"Key pressed: {event.keyval}, State: {event.state}") # Check if 'r' key was pressed for renaming if event.keyval == Gdk.KEY_r: self.start_rename(button, session_name, label, entry) return True # Check for 'K' (capital K) which indicates Shift is pressed elif event.keyval == Gdk.KEY_K: print("Shift+K detected - killing session without confirmation") self.kill_session(session_name) return True # Check for lowercase 'k' elif event.keyval == Gdk.KEY_k: print("Regular k detected - showing confirmation") self.show_kill_confirmation_menu(button, session_name) return True # Check if Delete key was pressed for killing session elif event.keyval == Gdk.KEY_Delete: self.show_kill_confirmation_menu(button, session_name) return True return False def show_kill_confirmation_menu(self, button, session_name): """Show a confirmation menu for killing a session""" menu = Gtk.Menu() # Confirmation message as a disabled menu item msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?") msg_item.set_sensitive(False) menu.append(msg_item) # Separator menu.append(Gtk.SeparatorMenuItem()) # Confirm option confirm_item = Gtk.MenuItem(label="Confirm") confirm_item.connect("activate", lambda _: self.kill_session(session_name)) menu.append(confirm_item) # Cancel option cancel_item = Gtk.MenuItem(label="Cancel") # Close launcher on cancel cancel_item.connect("activate", lambda _: self.close_manager()) menu.append(cancel_item) menu.show_all() # Show the menu positioned at the button menu.popup_at_widget( button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, None )