Skip to content
Open
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
2 changes: 1 addition & 1 deletion Default.sublime-commands
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"command": "open_terminal_project_folder"
},
{
"caption": "Terminal: Switch to application",
"caption": "Terminal: Switch to terminal",
"command": "switch_to_terminal"
}
]
198 changes: 193 additions & 5 deletions Terminal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import sublime
import sublime_plugin
import json
import os
import shutil
import sys
import subprocess

Expand All @@ -18,6 +20,11 @@ class NotFoundError(Exception):

INSTALLED_DIR = __name__.split('.')[0]

# Stack of terminal window IDs opened from Sublime (Linux only).
# SwitchToTerminalCommand activates the most recent live window,
# falling back to older ones when a terminal is closed.
_terminal_wid_stack = []


def get_setting(key, default=None):
settings = sublime.load_settings('Terminal.sublime-settings')
Expand Down Expand Up @@ -81,6 +88,128 @@ def linux_terminal():
return 'xterm'


def _linux_window_backend():
if sys.platform != 'linux':
return None
if os.environ.get('HYPRLAND_INSTANCE_SIGNATURE') and shutil.which('hyprctl'):
return 'hyprland'
if shutil.which('xdotool'):
return 'xdotool'
return None


def _has_linux_window_tool():
return _linux_window_backend() is not None


def _get_active_wid():
backend = _linux_window_backend()
try:
if backend == 'hyprland':
out = subprocess.check_output(
['hyprctl', 'activewindow', '-j'],
timeout=2, stderr=subprocess.DEVNULL
).decode().strip()
return json.loads(out).get('address')
elif backend == 'xdotool':
return subprocess.check_output(
['xdotool', 'getactivewindow'],
timeout=2, stderr=subprocess.DEVNULL
).decode().strip()
except (Exception):
pass
return None


def _find_wid_by_pid(pid):
backend = _linux_window_backend()
try:
if backend == 'hyprland':
out = subprocess.check_output(
['hyprctl', 'clients', '-j'],
timeout=2, stderr=subprocess.DEVNULL
).decode().strip()
for client in json.loads(out):
if client.get('pid') == pid:
return client.get('address')
elif backend == 'xdotool':
result = subprocess.check_output(
['xdotool', 'search', '--pid', str(pid)],
timeout=2, stderr=subprocess.DEVNULL
).decode().strip()
if result:
return result.splitlines()[-1]
except (Exception):
pass
return None


def _is_window_alive(wid):
backend = _linux_window_backend()
try:
if backend == 'hyprland':
out = subprocess.check_output(
['hyprctl', 'clients', '-j'],
timeout=2, stderr=subprocess.DEVNULL
).decode().strip()
return any(c.get('address') == wid for c in json.loads(out))
elif backend == 'xdotool':
subprocess.check_output(
['xdotool', 'getwindowname', wid],
timeout=2, stderr=subprocess.DEVNULL)
return True
except (Exception):
pass
return False


def _activate_window(wid):
backend = _linux_window_backend()
try:
if backend == 'hyprland':
subprocess.run(
['hyprctl', 'dispatch', 'focuswindow', 'address:' + wid],
timeout=2, stderr=subprocess.DEVNULL)
elif backend == 'xdotool':
subprocess.run(
['xdotool', 'windowactivate', wid],
timeout=2, stderr=subprocess.DEVNULL)
except (Exception):
pass


def _activate_by_class(class_name):
backend = _linux_window_backend()
try:
if backend == 'hyprland':
subprocess.run(
['hyprctl', 'dispatch', 'focuswindow', 'class:' + class_name],
timeout=2, stderr=subprocess.DEVNULL)
elif backend == 'xdotool':
subprocess.run([
'xdotool', 'search', '--class',
class_name, 'windowactivate',
], timeout=2, stderr=subprocess.DEVNULL)
except (Exception):
pass


def _tag_sublime_window():
"""Tag Sublime's window for Hyprland tag-based focus switching."""
if _linux_window_backend() == 'hyprland':
try:
# Plugins run in plugin_host (child process).
# The window belongs to sublime_text (parent process).
sublime_pid = os.getppid()
subprocess.run(
['hyprctl', 'dispatch', 'tagwindow',
'+sublime-opener pid:' + str(sublime_pid)],
timeout=2, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except (Exception):
pass


class TerminalSelector():
default = None

Expand Down Expand Up @@ -161,8 +290,34 @@ def open_terminal(self, location, terminal, parameters):
else:
env[k] = env_setting[k]

# On Linux, set up bidirectional focus switching.
# Hyprland: tag Sublime's window so terminals can focus
# back via hyprctl's tag selector. Env vars don't survive
# single-instance terminals (e.g. Ghostty gtk-single-instance).
# X11: inject Sublime's window ID as env var for xdotool.
if _has_linux_window_tool():
_tag_sublime_window()
if _linux_window_backend() == 'xdotool':
sublime_wid = _find_wid_by_pid(os.getppid()) or _get_active_wid()
if sublime_wid:
env['SUBLIME_TERMINAL_OPENER_WID'] = sublime_wid

# Run our process
subprocess.Popen(args, cwd=location, env=env)
proc = subprocess.Popen(args, cwd=location, env=env)

# On Linux, capture the new terminal's window ID after it
# appears and takes focus. Used by SwitchToTerminalCommand.
if _has_linux_window_tool():
def _capture_wid():
try:
wid = _find_wid_by_pid(proc.pid)
if not wid:
wid = _get_active_wid()
if wid and wid not in _terminal_wid_stack:
_terminal_wid_stack.append(wid)
except (Exception):
pass
sublime.set_timeout(_capture_wid, 1500)

except (OSError) as exception:
print(str(exception))
Expand Down Expand Up @@ -217,9 +372,42 @@ def run(self, paths=[], parameters=None):

class SwitchToTerminalCommand(sublime_plugin.WindowCommand, TerminalCommand):
def is_visible(self):
# only have an applescript to do this
return sys.platform == 'darwin'
if sys.platform == 'darwin':
return True
return _has_linux_window_tool()

def run(self, paths=[], parameters=None):
package_dir = os.path.join(sublime.packages_path(), INSTALLED_DIR)
subprocess.Popen(os.path.join(package_dir, 'TerminalSwitch.sh'))
if sys.platform == 'darwin':
package_dir = os.path.join(sublime.packages_path(), INSTALLED_DIR)
subprocess.run(os.path.join(package_dir, 'TerminalSwitch.sh'))
elif sys.platform == 'linux':
if not _has_linux_window_tool():
sublime.error_message(
'Terminal: No supported window tool found.\n'
'For X11, install xdotool:\n'
'- Debian/Ubuntu/Mint: sudo apt install xdotool\n'
'- Arch: sudo pacman -S xdotool\n'
'- Fedora: sudo dnf install xdotool\n'
'For Hyprland, hyprctl is detected automatically.\n'
'Other Wayland compositors are not currently supported.')
return

activated = False

# Walk the stack from most recent, prune dead windows
while _terminal_wid_stack:
wid = _terminal_wid_stack[-1]
if _is_window_alive(wid):
_activate_window(wid)
activated = True
break
else:
_terminal_wid_stack.pop()

if not activated:
# Fallback: activate by window class
terminal_class = get_setting('terminal_class', '')
if not terminal_class:
terminal = get_setting('terminal', '') or linux_terminal()
terminal_class = os.path.basename(terminal)
_activate_by_class(terminal_class)
11 changes: 9 additions & 2 deletions Terminal.sublime-settings
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
// The command to execute for the terminal, leave blank for the OS default
// See https://github.com/wbond/sublime_terminal#examples for examples
"terminal": "",
"terminal": null,

// A list of default parameters to pass to the terminal, this can be
// overridden by passing the "parameters" key with a list value to the args
Expand All @@ -16,5 +16,12 @@

// For the Mac Terminal.app only, allow reuse of the front-most window,
// instead of spawning a new window
"reuse_window": false
"reuse_window": false,

// The WM_CLASS of the terminal, used by "Switch to Terminal" on Linux.
// If not set, derived from the "terminal" setting. Override this if your
// terminal's WM_CLASS differs from its executable name (e.g. gnome-terminal
// uses "gnome-terminal-server").
// Find yours with: xdotool getactivewindow getwindowclassname
"terminal_class": null
}
Loading