Refactor to support multiple windows/tabs

This commit is contained in:
Kovid Goyal 2016-11-26 17:40:28 +05:30
parent 03f7ced17e
commit b5000c2ec0
10 changed files with 713 additions and 517 deletions

View File

@ -1,342 +0,0 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import os
import io
import signal
import select
import subprocess
import struct
from itertools import repeat
from functools import partial
from time import monotonic
from threading import Thread, current_thread
from queue import Queue, Empty
import glfw
import glfw_constants
from .constants import appname
from .char_grid import CharGrid
from .keys import interpret_text_event, interpret_key_event, get_shortcut
from .utils import sanitize_title, parse_color_set
from .fast_data_types import (
BRACKETED_PASTE_START, BRACKETED_PASTE_END, Screen, read_bytes_dump, read_bytes
)
from .terminfo import get_capabilities
def handle_unix_signals():
read_fd, write_fd = os.pipe2(os.O_NONBLOCK | os.O_CLOEXEC)
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, lambda x, y: None)
signal.siginterrupt(sig, False)
signal.set_wakeup_fd(write_fd)
return read_fd
class Boss(Thread):
daemon = True
shutting_down = False
pending_title_change = pending_icon_change = None
pending_color_changes = {}
def __init__(self, window, window_width, window_height, opts, args, child):
Thread.__init__(self, name='ChildMonitor')
self.child = child
self.screen_update_delay = opts.repaint_delay / 1000.0
self.pending_update_screen = None
self.action_queue = Queue()
self.child.fork()
self.child_fd = self.child.child_fd
self.read_wakeup_fd, self.write_wakeup_fd = os.pipe2(os.O_NONBLOCK | os.O_CLOEXEC)
self.signal_fd = handle_unix_signals()
self.readers = [self.child_fd, self.signal_fd, self.read_wakeup_fd]
self.writers = [self.child_fd]
self.profile = args.profile
self.window, self.opts = window, opts
self.screen = Screen(self, 24, 80, opts.scrollback_lines)
self.read_bytes = partial(read_bytes_dump, self.dump_commands) if args.dump_commands else read_bytes
self.draw_dump_buf = []
self.write_buf = memoryview(b'')
self.char_grid = CharGrid(self.screen, opts, window_width, window_height)
glfw.glfwSetCharModsCallback(window, self.on_text_input)
glfw.glfwSetKeyCallback(window, self.on_key)
glfw.glfwSetMouseButtonCallback(window, self.on_mouse_button)
glfw.glfwSetWindowFocusCallback(window, self.on_focus)
def dump_commands(self, *a):
if a:
if a[0] == 'draw':
if a[1] is None:
if self.draw_dump_buf:
print('draw', ''.join(self.draw_dump_buf))
self.draw_dump_buf = []
else:
self.draw_dump_buf.append(a[1])
else:
if self.draw_dump_buf:
print('draw', ''.join(self.draw_dump_buf))
self.draw_dump_buf = []
print(*a)
def queue_action(self, func, *args):
self.action_queue.put((func, args))
self.wakeup()
def wakeup(self):
os.write(self.write_wakeup_fd, b'1')
def on_wakeup(self):
try:
os.read(self.read_wakeup_fd, io.DEFAULT_BUFFER_SIZE)
except (EnvironmentError, BlockingIOError):
pass
while not self.shutting_down:
try:
func, args = self.action_queue.get_nowait()
except Empty:
break
func(*args)
def signal_received(self):
try:
data = os.read(self.signal_fd, io.DEFAULT_BUFFER_SIZE)
except BlockingIOError:
return
if data:
signals = struct.unpack('%uB' % len(data), data)
if signal.SIGINT in signals or signal.SIGTERM in signals:
self.shutdown()
def on_focus(self, window, focused):
if focused:
if self.screen.focus_tracking_enabled():
self.write_to_child(b'\x1b[I')
else:
if self.screen.focus_tracking_enabled():
self.write_to_child(b'\x1b[O')
def on_mouse_button(self, window, button, action, mods):
if action == glfw_constants.GLFW_RELEASE:
if button == glfw_constants.GLFW_MOUSE_BUTTON_MIDDLE:
self.paste_from_selection()
return
def on_key(self, window, key, scancode, action, mods):
if action == glfw_constants.GLFW_PRESS or action == glfw_constants.GLFW_REPEAT:
func = get_shortcut(self.opts.keymap, mods, key)
if func is not None:
func = getattr(self, func, None)
if func is not None:
passthrough = func()
if not passthrough:
return
if self.char_grid.scrolled_by:
self.scroll_end()
data = interpret_key_event(key, scancode, mods)
if data:
self.write_to_child(data)
def on_text_input(self, window, codepoint, mods):
data = interpret_text_event(codepoint, mods)
if data:
self.write_to_child(data)
def on_window_resize(self, window, w, h):
self.queue_action(self.apply_resize_screen, w, h)
def apply_resize_screen(self, w, h):
self.char_grid.resize_screen(w, h)
sg = self.char_grid.screen_geometry
self.child.resize_pty(sg.xnum, sg.ynum)
glfw.glfwPostEmptyEvent()
def apply_opts(self, opts):
self.opts = opts
self.screen_update_delay = opts.repaint_delay / 1000.0
self.queue_action(self.apply_opts_to_screen)
def apply_opts_to_screen(self):
self.char_grid.apply_opts(self.opts)
self.char_grid.dirty_everything()
self.screen.change_scrollback_size(self.opts.scrollback_lines)
def render(self):
if self.pending_title_change is not None:
t, self.pending_title_change = sanitize_title(self.pending_title_change or appname), None
glfw.glfwSetWindowTitle(self.window, t.encode('utf-8'))
if self.pending_icon_change is not None:
self.pending_icon_change = None # TODO: Implement this
self.char_grid.render()
def run(self):
if self.profile:
import cProfile
import pstats
pr = cProfile.Profile()
pr.enable()
self.loop()
if self.profile:
pr.disable()
pr.create_stats()
s = pstats.Stats(pr)
s.dump_stats(self.profile)
def loop(self):
all_readers, all_writers = self.readers, self.writers
dispatch = list(repeat(None, max(all_readers) + 1))
dispatch[self.child_fd] = self.read_ready
dispatch[self.read_wakeup_fd] = self.on_wakeup
dispatch[self.signal_fd] = self.signal_received
while not self.shutting_down:
timeout = None if self.pending_update_screen is None else max(0, self.pending_update_screen - monotonic())
readers, writers, _ = select.select(all_readers, all_writers if self.write_buf else [], [], timeout)
for r in readers:
dispatch[r]()
if writers:
self.write_ready()
if self.pending_update_screen is not None:
if monotonic() > self.pending_update_screen:
self.apply_update_screen()
elif self.screen.is_dirty():
self.pending_update_screen = monotonic() + self.screen_update_delay
def close(self):
if not self.shutting_down:
self.queue_action(self.shutdown)
def destroy(self):
# Must be called in the main thread as it manipulates signal handlers
signal.signal(signal.SIGINT, signal.SIG_DFL)
signal.signal(signal.SIGTERM, signal.SIG_DFL)
self.char_grid.destroy()
self.child.hangup()
self.child.get_child_status() # Ensure child does not become zombie
def shutdown(self):
self.shutting_down = True
glfw.glfwSetWindowShouldClose(self.window, True)
glfw.glfwPostEmptyEvent()
def read_ready(self):
if self.shutting_down:
return
if self.read_bytes(self.screen, self.child_fd) is False:
self.shutdown() # EOF
def write_ready(self):
if not self.shutting_down:
while self.write_buf:
try:
n = os.write(self.child_fd, self.write_buf)
except BlockingIOError:
n = 0
if not n:
return
self.write_buf = self.write_buf[n:]
def write_to_child(self, data):
if data:
if current_thread() is self:
self.queue_write(data)
else:
self.queue_action(self.queue_write, data)
def queue_write(self, data):
self.write_buf = memoryview(self.write_buf.tobytes() + data)
def apply_update_screen(self):
self.pending_update_screen = None
self.char_grid.update_cell_data()
glfw.glfwPostEmptyEvent()
def title_changed(self, new_title):
self.pending_title_change = new_title
glfw.glfwPostEmptyEvent()
def icon_changed(self, new_icon):
self.pending_icon_change = new_icon
glfw.glfwPostEmptyEvent()
def set_dynamic_color(self, code, value):
wmap = {10: 'fg', 11: 'bg', 110: 'fg', 111: 'bg'}
if isinstance(value, bytes):
value = value.decode('utf-8')
for val in value.split(';'):
w = wmap.get(code)
if w is not None:
if code >= 110:
val = None
self.pending_color_changes[w] = val
code += 1
self.queue_action(self.apply_change_colors)
def refresh(self):
self.screen.mark_as_dirty()
self.wakeup()
def set_color_table_color(self, code, value):
if code == 4:
for c, val in parse_color_set(value):
self.char_grid.color_profile.set_color(c, val)
self.refresh()
elif code == 104:
if not value.strip():
self.char_grid.color_profile.reset_color_table()
else:
for c in value.split(';'):
try:
c = int(c)
except Exception:
continue
if 0 <= c <= 255:
self.char_grid.color_profile.reset_color(c)
self.refresh()
def apply_change_colors(self):
self.char_grid.change_colors(self.pending_color_changes)
self.pending_color_changes = {}
glfw.glfwPostEmptyEvent()
def request_capabilities(self, q):
self.write_to_child(get_capabilities(q))
# actions {{{
def paste(self, text):
if text:
if self.screen.in_bracketed_paste_mode():
text = BRACKETED_PASTE_START.encode('ascii') + text + BRACKETED_PASTE_END.encode('ascii')
self.write_to_child(text)
def paste_from_clipboard(self):
text = glfw.glfwGetClipboardString(self.window)
self.paste(text)
def paste_from_selection(self):
# glfw has no way to get the primary selection
# https://github.com/glfw/glfw/issues/894
text = subprocess.check_output(['xsel'])
self.paste(text)
def scroll_line_up(self):
self.queue_action(self.char_grid.scroll, 'line', True)
def scroll_line_down(self):
self.queue_action(self.char_grid.scroll, 'line', False)
def scroll_page_up(self):
self.queue_action(self.char_grid.scroll, 'page', True)
def scroll_page_down(self):
self.queue_action(self.char_grid.scroll, 'page', False)
def scroll_home(self):
self.queue_action(self.char_grid.scroll, 'full', True)
def scroll_end(self):
self.queue_action(self.char_grid.scroll, 'full', False)
# }}}

View File

@ -4,24 +4,19 @@
from collections import namedtuple
from ctypes import c_uint, addressof, memmove, sizeof
from queue import Queue, Empty
from itertools import count
from threading import Lock
from .config import build_ansi_color_table
from .fonts import set_font_family
from .shaders import Sprites, ShaderProgram
from .constants import tab_manager, viewport_size, cell_size, ScreenGeometry
from .utils import get_logical_dpi, to_color
from .fast_data_types import (
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, glClear,
GL_COLOR_BUFFER_BIT, glClearColor, glViewport, glUniform2ui, glUniform4f,
glUniform1i, glUniform2f, glDrawArraysInstanced, GL_TRIANGLE_FAN,
glEnable, glDisable, GL_BLEND, glDrawArrays, ColorProfile,
glUniform2ui, glUniform4f, glUniform1i, glUniform2f, glDrawArraysInstanced,
GL_TRIANGLE_FAN, glEnable, glDisable, GL_BLEND, glDrawArrays, ColorProfile,
CURSOR_BEAM, CURSOR_BLOCK, CURSOR_UNDERLINE, DATA_CELL_SIZE
)
Size = namedtuple('Size', 'width height')
Cursor = namedtuple('Cursor', 'x y hidden shape color blink')
ScreenGeometry = namedtuple('ScreenGeometry', 'xstart ystart xnum ynum dx dy')
if DATA_CELL_SIZE % 3:
raise ValueError('Incorrect data cell size, must be a multiple of 3')
@ -150,96 +145,54 @@ void main() {
# }}}
def calculate_screen_geometry(cell_width, cell_height, screen_width, screen_height):
xnum = screen_width // cell_width
ynum = screen_height // cell_height
dx, dy = 2 * cell_width / screen_width, 2 * cell_height / screen_height
xmargin = (screen_width - (xnum * cell_width)) / screen_width
ymargin = (screen_height - (ynum * cell_height)) / screen_height
xstart = -1 + xmargin
ystart = 1 - ymargin
return ScreenGeometry(xstart, ystart, xnum, ynum, dx, dy)
class RenderData:
__slots__ = 'viewport clear_color cell_data_changed screen_geometry sprite_layout cursor'.split()
def __init__(self, viewport=None, clear_color=None, cell_data_changed=False, screen_geometry=None, sprite_layout=None, cursor=None):
self.viewport, self.clear_color, self.cell_data_changed = viewport, clear_color, cell_data_changed
self.screen_geometry = screen_geometry
self.sprite_layout = sprite_layout
self.cursor = cursor
def update(self, other):
for k in self.__slots__:
val = getattr(other, k)
if val is not None:
setattr(self, k, val)
def color_as_int(val):
return val[0] << 16 | val[1] << 8 | val[2]
render_data_num = count()
class CharGrid:
def __init__(self, screen, opts, window_width, window_height):
self.sprites_lock, self.buffer_lock = Lock(), Lock()
def __init__(self, screen, opts):
self.buffer_lock = Lock()
self.render_num = next(render_data_num)
self.render_data = None
self.last_render_send_num = -1
self.scrolled_by = 0
self.dpix, self.dpiy = get_logical_dpi()
self.width, self.height = window_width, window_height
self.color_profile = ColorProfile()
self.as_color = self.color_profile.as_color
self.color_profile.update_ansi_color_table(build_ansi_color_table(opts))
self.screen = screen
self.opts = opts
self.original_bg = opts.background
self.original_fg = opts.foreground
self.render_queue = Queue()
self.program = ShaderProgram(*cell_shader)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
self.sprites = Sprites()
self.cursor_program = ShaderProgram(*cursor_shader)
self.last_render_data = RenderData()
self.default_cursor = Cursor(0, 0, False, opts.cursor_shape, opts.cursor, opts.cursor_blink)
self.render_queue.put(RenderData(
viewport=Size(self.width, self.height), clear_color=color_as_int(self.original_bg),
cursor=self.default_cursor))
self.clear_count = 4
self.default_bg = color_as_int(self.original_bg)
self.default_fg = color_as_int(self.original_fg)
self.apply_opts(self.opts)
def destroy(self):
self.sprites.destroy()
def apply_opts(self, opts):
self.dpix, self.dpiy = get_logical_dpi()
self.opts = opts
self.screen.change_scrollback_size(opts.scrollback_lines)
self.color_profile.update_ansi_color_table(build_ansi_color_table(opts))
self.default_cursor = Cursor(0, 0, False, opts.cursor_shape, opts.cursor, opts.cursor_blink)
self.default_cursor = self.current_cursor = Cursor(0, 0, False, opts.cursor_shape, opts.cursor, opts.cursor_blink)
self.opts = opts
self.original_bg = opts.background
self.original_fg = opts.foreground
self.cell_width, self.cell_height = set_font_family(opts.font_family, opts.font_size)
self.sprites.do_layout(self.cell_width, self.cell_height)
self.do_layout(self.width, self.height)
self.sprite_map_type = self.main_sprite_map = self.scroll_sprite_map = self.render_buf = None
def resize_screen(self, w, h):
' Screen was resized by the user (called in non-UI thread) '
self.do_layout(w, h)
def update_position(self, window_geometry):
dx, dy = 2 * cell_size.width / viewport_size.width, 2 * cell_size.height / viewport_size.height
xmargin = window_geometry.left / viewport_size.width
ymargin = window_geometry.top / viewport_size.height
xstart = -1 + xmargin
ystart = 1 - ymargin
self.screen_geometry = ScreenGeometry(xstart, ystart, window_geometry.xnum, window_geometry.ynum, dx, dy)
def do_layout(self, w, h):
self.width, self.height = w, h
self.screen_geometry = sg = calculate_screen_geometry(self.cell_width, self.cell_height, self.width, self.height)
self.screen.resize(sg.ynum, sg.xnum)
self.sprite_map_type = (c_uint * (sg.ynum * sg.xnum * DATA_CELL_SIZE))
def resize(self, window_geometry):
self.update_position(window_geometry)
self.sprite_map_type = (c_uint * (self.screen_geometry.ynum * self.screen_geometry.xnum * DATA_CELL_SIZE))
self.main_sprite_map = self.sprite_map_type()
self.scroll_sprite_map = self.sprite_map_type()
self.render_buf = self.sprite_map_type()
self.update_cell_data(add_viewport_data=True)
self.clear_count = 4
with self.buffer_lock:
self.render_buf = self.sprite_map_type()
self.render_num = next(render_data_num)
def change_colors(self, changes):
dirtied = False
@ -254,8 +207,7 @@ class CharGrid:
setattr(self, 'default_' + which, color_as_int(val))
dirtied = True
if dirtied:
self.render_queue.put(RenderData(clear_color=self.default_bg))
self.clear_count = 4
self.screen.mark_as_dirty()
def scroll(self, amt, upwards=True):
amt = {'line': 1, 'page': self.screen.lines - 1, 'full': self.screen.historybuf.count}[amt]
@ -266,77 +218,49 @@ class CharGrid:
self.scrolled_by = y
self.update_cell_data()
def update_cell_data(self, add_viewport_data=False):
rd = RenderData(sprite_layout=self.sprites.layout, cell_data_changed=True)
if add_viewport_data:
rd.viewport = Size(self.width, self.height)
rd.screen_geometry = self.screen_geometry
with self.sprites_lock:
def update_cell_data(self, force_full_refresh=False):
sprites = tab_manager().sprites
with sprites.lock:
cursor_changed, history_line_added_count = self.screen.update_cell_data(
self.sprites.backend, self.color_profile, addressof(self.main_sprite_map), self.default_fg, self.default_bg, add_viewport_data)
sprites.backend, self.color_profile, addressof(self.main_sprite_map), self.default_fg, self.default_bg, force_full_refresh)
if self.scrolled_by:
self.scrolled_by = min(self.scrolled_by + history_line_added_count, self.screen.historybuf.count)
self.screen.set_scroll_cell_data(
self.sprites.backend, self.color_profile, addressof(self.main_sprite_map), self.default_fg, self.default_bg,
sprites.backend, self.color_profile, addressof(self.main_sprite_map), self.default_fg, self.default_bg,
self.scrolled_by, addressof(self.scroll_sprite_map))
data = self.scroll_sprite_map if self.scrolled_by else self.main_sprite_map
with self.buffer_lock:
memmove(self.render_buf, data, sizeof(type(data)))
self.render_num = next(render_data_num)
self.render_data = self.screen_geometry
if cursor_changed:
c = self.screen.cursor
rd.cursor = Cursor(c.x, c.y, c.hidden, c.shape, c.color, c.blink)
self.render_queue.put(rd)
self.current_cursor = Cursor(c.x, c.y, c.hidden, c.shape, c.color, c.blink)
def render(self):
' This is the only method in this class called in the UI thread (apart from __init__) '
if self.clear_count > 0:
glClear(GL_COLOR_BUFFER_BIT)
self.clear_count -= 1
cell_data_changed = self.get_all_render_changes()
with self.sprites:
with self.sprites_lock:
self.sprites.render_dirty_cells()
if cell_data_changed:
with self.buffer_lock:
self.sprites.set_sprite_map(self.render_buf)
data = self.last_render_data
if data.screen_geometry is None:
def prepare_for_render(self, sprites):
with self.buffer_lock:
sg = self.render_data
if sg is None:
return
sg = data.screen_geometry
self.render_cells(sg, data.sprite_layout)
if not data.cursor.hidden and not self.scrolled_by:
self.render_cursor(sg, data.cursor)
if self.last_render_send_num != self.render_num:
sprites.set_sprite_map(self.render_buf)
self.last_render_send_num = self.render_num
return sg
def get_all_render_changes(self):
cell_data_changed = False
data = self.last_render_data
while True:
try:
rd = self.render_queue.get_nowait()
except Empty:
break
cell_data_changed |= rd.cell_data_changed
if rd.clear_color is not None:
bg = rd.clear_color
glClearColor((bg >> 16) / 255, ((bg >> 8) & 0xff) / 255, (bg & 0xff) / 255, 1)
if rd.viewport is not None:
glViewport(0, 0, self.width, self.height)
data.update(rd)
return cell_data_changed
def render_cells(self, sg, cell_program, sprites):
ul = cell_program.uniform_location
glUniform2ui(ul('dimensions'), sg.xnum, sg.ynum)
glUniform4f(ul('steps'), sg.xstart, sg.ystart, sg.dx, sg.dy)
glUniform1i(ul('sprites'), sprites.sampler_num)
glUniform1i(ul('sprite_map'), sprites.buffer_sampler_num)
glUniform2f(ul('sprite_layout'), *(sprites.layout))
glDrawArraysInstanced(GL_TRIANGLE_FAN, 0, 4, sg.xnum * sg.ynum)
def render_cells(self, sg, sprite_layout):
with self.program:
ul = self.program.uniform_location
glUniform2ui(ul('dimensions'), sg.xnum, sg.ynum)
glUniform4f(ul('steps'), sg.xstart, sg.ystart, sg.dx, sg.dy)
glUniform1i(ul('sprites'), self.sprites.sampler_num)
glUniform1i(ul('sprite_map'), self.sprites.buffer_sampler_num)
glUniform2f(ul('sprite_layout'), *sprite_layout)
glDrawArraysInstanced(GL_TRIANGLE_FAN, 0, 4, sg.xnum * sg.ynum)
def render_cursor(self, sg, cursor):
def render_cursor(self, sg, cursor_program):
cursor = self.current_cursor
if cursor.hidden or self.scrolled_by:
return
def width(w=2, vert=True):
dpi = self.dpix if vert else self.dpiy
@ -344,22 +268,21 @@ class CharGrid:
factor = 2 / (self.width if vert else self.height)
return w * factor
with self.cursor_program:
ul = self.cursor_program.uniform_location
left = sg.xstart + cursor.x * sg.dx
top = sg.ystart - cursor.y * sg.dy
col = cursor.color or self.default_cursor.color
shape = cursor.shape or self.default_cursor.shape
alpha = self.opts.cursor_opacity
if alpha < 1.0 and shape == CURSOR_BLOCK:
glEnable(GL_BLEND)
mult = self.screen.current_char_width()
right = left + (width(1.5) if shape == CURSOR_BEAM else sg.dx * mult)
bottom = top - sg.dy
if shape == CURSOR_UNDERLINE:
top = bottom + width(vert=False)
glUniform4f(ul('color'), col[0], col[1], col[2], alpha)
glUniform2f(ul('xpos'), left, right)
glUniform2f(ul('ypos'), top, bottom)
glDrawArrays(GL_TRIANGLE_FAN, 0, 4)
glDisable(GL_BLEND)
ul = cursor_program.uniform_location
left = sg.xstart + cursor.x * sg.dx
top = sg.ystart - cursor.y * sg.dy
col = cursor.color or self.default_cursor.color
shape = cursor.shape or self.default_cursor.shape
alpha = self.opts.cursor_opacity
if alpha < 1.0 and shape == CURSOR_BLOCK:
glEnable(GL_BLEND)
mult = self.screen.current_char_width()
right = left + (width(1.5) if shape == CURSOR_BEAM else sg.dx * mult)
bottom = top - sg.dy
if shape == CURSOR_UNDERLINE:
top = bottom + width(vert=False)
glUniform4f(ul('color'), col[0], col[1], col[2], alpha)
glUniform2f(ul('xpos'), left, right)
glUniform2f(ul('ypos'), top, bottom)
glDrawArrays(GL_TRIANGLE_FAN, 0, 4)
glDisable(GL_BLEND)

View File

@ -4,10 +4,14 @@
import os
import threading
import pwd
from collections import namedtuple
appname = 'kitty'
version = (0, 1, 0)
str_version = '.'.join(map(str, version))
ScreenGeometry = namedtuple('ScreenGeometry', 'xstart ystart xnum ynum dx dy')
WindowGeometry = namedtuple('WindowGeometry', 'left top right bottom xnum ynum')
def _get_config_dir():
@ -22,8 +26,38 @@ def _get_config_dir():
except FileExistsError:
pass
return ans
config_dir = _get_config_dir()
del _get_config_dir
class ViewportSize:
__slots__ = ('width', 'height')
def __init__(self):
self.width = self.height = 1024
def tab_manager():
return tab_manager.manager
def set_tab_manager(m):
tab_manager.manager = m
def wakeup():
os.write(tab_manager.manager.write_wakeup_fd, b'1')
def queue_action(func, *args):
tab_manager.manager.queue_action(func, *args)
viewport_size = ViewportSize()
cell_size = ViewportSize()
terminfo_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'terminfo')
main_thread = threading.current_thread()
shell_path = pwd.getpwuid(os.geteuid()).pw_shell or '/bin/sh'

57
kitty/layout.py Normal file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from .constants import WindowGeometry, viewport_size, cell_size
def layout_dimension(length, cell_length, number_of_windows=1, border_length=0):
number_of_cells = length // cell_length
space_needed_for_border = number_of_windows * border_length
extra = length - number_of_cells * cell_length
while extra < space_needed_for_border:
number_of_cells -= 1
extra = length - number_of_cells * cell_length
extra -= space_needed_for_border
pos = (extra // 2) + border_length
inner_length = number_of_cells * cell_length
window_length = 2 * border_length + inner_length
while number_of_windows > 0:
number_of_windows -= 1
yield pos, number_of_cells
pos += window_length
class Layout:
name = None
def add_window(self, windows, window):
raise NotImplementedError()
def remove_window(self, windows, window):
raise NotImplementedError()
def __call__(self, windows):
raise NotImplementedError()
class Stack:
name = 'stack'
def add_window(self, windows, window):
windows.appendleft(window)
self(windows)
def remove_window(self, windows, window):
windows.remove(window)
self(windows)
def __call__(self, windows):
xstart, xnum = next(layout_dimension(viewport_size.width, cell_size.width))
ystart, ynum = next(layout_dimension(viewport_size.height, cell_size.height))
wg = WindowGeometry(left=xstart, top=ystart, xnum=xnum, ynum=ynum, right=xstart + cell_size.width * xnum, bottom=ystart + cell_size.height * ynum)
for i, w in enumerate(windows):
w.is_visible_in_layout = i == 0
w.set_geometry(wg)

View File

@ -6,17 +6,16 @@ import argparse
import tempfile
import os
import sys
import pwd
from gettext import gettext as _
from .child import Child
from .config import load_config
from .constants import appname, str_version, config_dir
from .boss import Boss
from .constants import appname, str_version, config_dir, viewport_size
from .tabs import TabManager
from .shaders import GL_VERSION
from .fast_data_types import glewInit, enable_automatic_opengl_error_checking
import glfw, glfw_constants
import glfw
import glfw_constants
def option_parser():
@ -44,11 +43,10 @@ def setup_opengl():
glfw.glfwWindowHint(glfw_constants.GLFW_SAMPLES, 0)
def run_app(opts, args, child):
def run_app(opts, args):
setup_opengl()
window_width = window_height = 1024
window = glfw.glfwCreateWindow(
window_width, window_height, args.cls.encode('utf-8'), None, None)
viewport_size.width, viewport_size.height, args.cls.encode('utf-8'), None, None)
if not window:
raise SystemExit("glfwCreateWindow failed")
glfw.glfwSetWindowTitle(window, appname.encode('utf-8'))
@ -56,19 +54,15 @@ def run_app(opts, args, child):
glfw.glfwMakeContextCurrent(window)
glewInit()
glfw.glfwSwapInterval(1)
boss = Boss(window, window_width, window_height, opts, args, child)
glfw.glfwSetFramebufferSizeCallback(window, boss.on_window_resize)
boss.start()
tabs = TabManager(window, opts, args)
tabs.start()
try:
while not glfw.glfwWindowShouldClose(window):
boss.render()
tabs.render()
glfw.glfwSwapBuffers(window)
glfw.glfwWaitEvents()
finally:
if boss.is_alive():
boss.close()
boss.join()
boss.destroy()
tabs.destroy()
finally:
glfw.glfwDestroyWindow(window)
@ -88,8 +82,6 @@ def main():
exec(args.cmd)
return
opts = load_config(args.config)
child = args.args or [pwd.getpwuid(os.geteuid()).pw_shell or '/bin/sh']
child = Child(child, args.directory, opts)
glfw.glfwSetErrorCallback(on_glfw_error)
enable_automatic_opengl_error_checking(False)
if not glfw.glfwInit():
@ -102,7 +94,7 @@ def main():
import pstats
pr = cProfile.Profile()
pr.enable()
run_app(opts, args, child)
run_app(opts, args)
pr.disable()
pr.create_stats()
s = pstats.Stats(pr)
@ -112,7 +104,7 @@ def main():
s.sort_stats('time', 'name')
s.print_stats(30)
else:
run_app(opts, args, child)
run_app(opts, args)
finally:
glfw.glfwTerminate()
os.closerange(3, 100)

View File

@ -911,7 +911,7 @@ erase_in_display(Screen *self, PyObject *args) {
#define MODE_GETTER(name, uname) \
static PyObject* name(Screen *self) { PyObject *ans = self->modes.m##uname ? Py_True : Py_False; Py_INCREF(ans); return ans; }
MODE_GETTER(in_bracketed_paste, BRACKETED_PASTE)
MODE_GETTER(in_bracketed_paste_mode, BRACKETED_PASTE)
MODE_GETTER(focus_tracking_enabled, FOCUS_TRACKING)
MODE_GETTER(mouse_button_tracking_enabled, MOUSE_BUTTON_TRACKING)
MODE_GETTER(mouse_motion_tracking_enabled, MOUSE_MOTION_TRACKING)
@ -1068,7 +1068,7 @@ static PyMethodDef methods[] = {
MND(mark_as_dirty, METH_NOARGS)
MND(resize, METH_VARARGS)
MND(set_scroll_cell_data, METH_VARARGS)
MND(in_bracketed_paste, METH_NOARGS)
MND(in_bracketed_paste_mode, METH_NOARGS)
MND(focus_tracking_enabled, METH_NOARGS)
MND(mouse_button_tracking_enabled, METH_NOARGS)
MND(mouse_motion_tracking_enabled, METH_NOARGS)

View File

@ -4,6 +4,7 @@
from ctypes import addressof, sizeof
from functools import lru_cache
from threading import Lock
from .fonts import render_cell
from .fast_data_types import (
@ -48,6 +49,7 @@ class Sprites:
self.last_ynum = -1
self.texture_unit = GL_TEXTURE0
self.backend = SpriteMap(glGetIntegerv(GL_MAX_TEXTURE_SIZE), glGetIntegerv(GL_MAX_ARRAY_TEXTURE_LAYERS))
self.lock = Lock()
def do_layout(self, cell_width=1, cell_height=1):
self.cell_width, self.cell_height = cell_width, cell_height
@ -84,7 +86,8 @@ class Sprites:
return first
def render_dirty_cells(self):
self.backend.render_dirty_cells(self.render_cell, self.send_to_gpu)
with self.lock:
self.backend.render_dirty_cells(self.render_cell, self.send_to_gpu)
def send_to_gpu(self, x, y, z, buf):
if self.backend.z >= self.last_num_of_layers:

356
kitty/tabs.py Normal file
View File

@ -0,0 +1,356 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import os
import io
import select
import signal
import struct
from collections import deque
from functools import partial
from itertools import count
from threading import Thread
from time import monotonic
from queue import Queue, Empty
import glfw
import glfw_constants
from .child import Child
from .constants import viewport_size, shell_path, appname, set_tab_manager, tab_manager, wakeup, cell_size
from .fast_data_types import glViewport, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA
from .fonts import set_font_family
from .char_grid import cursor_shader, cell_shader
from .keys import interpret_text_event, interpret_key_event, get_shortcut
from .layout import Stack
from .shaders import Sprites, ShaderProgram
from .utils import handle_unix_signals
from .window import Window
timer_id = count()
class Tab:
def __init__(self, opts, args):
self.opts, self.args = opts, args
self.windows = deque()
self.current_layout = Stack()
@property
def is_visible(self):
return tab_manager().is_tab_visible(self)
@property
def active_window(self):
return self.windows[0] if self.windows else None
@property
def title(self):
return getattr(self.active_window, 'title', appname)
def visible_windows(self):
for w in self.windows:
if w.is_visible_in_layout:
yield w
def relayout(self):
if self.windows:
self.current_layout(self.windows)
def launch_child(self, use_shell=False):
if use_shell:
cmd = [shell_path]
else:
cmd = self.args.args or [shell_path]
ans = Child(cmd, self.args.directory, self.opts)
ans.fork()
return ans
def new_window(self, use_shell=False):
child = self.launch_child(use_shell=use_shell)
window = Window(self, child, self.opts, self.args)
tab_manager().add_child_fd(child.child_fd, window.read_ready, window.write_ready)
self.current_layout.add_window(self.windows, window)
def remove_window(self, window):
self.current_layout.remove_window(self.windows, window)
def __iter__(self):
yield from iter(self.windows)
def __len__(self):
return len(self.windows)
def __contains__(self, window):
return window in self.windows
def destroy(self):
for w in self.windows:
w.destroy()
del self.windows
def render(self):
# TODO: Render window borders and clear the extra pixels
pass
class TabManager(Thread):
daemon = True
def __init__(self, glfw_window, opts, args):
Thread.__init__(self, name='ChildMonitor')
self.glfw_window_title = None
self.action_queue = Queue()
self.pending_resize = None
self.resize_gl_viewport = False
self.shutting_down = False
self.screen_update_delay = opts.repaint_delay / 1000.0
self.signal_fd = handle_unix_signals()
self.read_wakeup_fd, self.write_wakeup_fd = os.pipe2(os.O_NONBLOCK | os.O_CLOEXEC)
self.read_dispatch_map = {self.signal_fd: self.signal_received, self.read_wakeup_fd: self.on_wakeup}
self.all_writers = []
self.timers = []
self.write_dispatch_map = {}
set_tab_manager(self)
cell_size.width, cell_size.height = set_font_family(opts.font_family, opts.font_size)
self.opts, self.args = opts, args
self.glfw_window = glfw_window
glfw.glfwSetFramebufferSizeCallback(glfw_window, partial(self.queue_action, self.on_window_resize))
glfw.glfwSetCharModsCallback(glfw_window, partial(self.queue_action, self.on_text_input))
glfw.glfwSetKeyCallback(glfw_window, partial(self.queue_action, self.on_key))
glfw.glfwSetMouseButtonCallback(glfw_window, partial(self.queue_action, self.on_mouse_button))
glfw.glfwSetWindowFocusCallback(glfw_window, partial(self.queue_action, self.on_focus))
self.tabs = deque()
self.tabs.append(Tab(opts, args))
self.sprites = Sprites()
self.cell_program = ShaderProgram(*cell_shader)
self.cursor_program = ShaderProgram(*cursor_shader)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
self.sprites.do_layout(cell_size.width, cell_size.height)
self.queue_action(self.active_tab.new_window, False)
def signal_received(self):
try:
data = os.read(self.signal_fd, io.DEFAULT_BUFFER_SIZE)
except BlockingIOError:
return
if data:
signals = struct.unpack('%uB' % len(data), data)
if signal.SIGINT in signals or signal.SIGTERM in signals:
self.shutdown()
def shutdown(self):
if not self.shutting_down:
self.shutting_down = True
glfw.glfwSetWindowShouldClose(self.glfw_window, True)
glfw.glfwPostEmptyEvent()
def __iter__(self):
yield from iter(self.tabs)
def iterwindows(self):
for t in self:
yield from t
def queue_action(self, func, *args):
self.action_queue.put((func, args))
wakeup()
def on_wakeup(self):
if not self.shutting_down:
try:
os.read(self.read_wakeup_fd, io.DEFAULT_BUFFER_SIZE)
except (EnvironmentError, BlockingIOError):
pass
while True:
try:
func, args = self.action_queue.get_nowait()
except Empty:
break
func(*args)
def add_child_fd(self, child_fd, read_ready, write_ready):
self.read_dispatch_map[child_fd] = read_ready
self.write_dispatch_map[child_fd] = write_ready
def remove_child_fd(self, child_fd):
self.read_dispatch_map.pop(child_fd, None)
self.write_dispatch_map.pop(child_fd, None)
try:
self.all_writers.remove(child_fd)
except Exception:
pass
def close_window(self, window):
self.remove_child_fd(window.child_fd)
for tab in self.tabs:
if window in tab:
break
else:
return
tab.remove_window(window)
window.destroy()
if len(tab) == 0:
self.tabs.remove(tab)
tab.destroy()
if len(self.tabs) == 0:
self.shutdown()
def call_after(self, delay, callback):
tid = next(timer_id)
self.timers.append((monotonic() + delay, tid, callback))
if len(self.timers) > 1:
self.timers.sort()
return tid
def run(self):
if self.args.profile:
import cProfile
import pstats
pr = cProfile.Profile()
pr.enable()
self.loop()
if self.args.profile:
pr.disable()
pr.create_stats()
s = pstats.Stats(pr)
s.dump_stats(self.args.profile)
def loop(self):
while not self.shutting_down:
all_readers = list(self.read_dispatch_map)
all_writers = [w.child_fd for w in self.iterwindows() if w.write_buf]
timeout = max(0, self.timers[0][0] - monotonic()) if self.timers else None
readers, writers, _ = select.select(all_readers, all_writers, [], timeout)
for r in readers:
self.read_dispatch_map[r]()
for w in writers:
self.write_dispatch_map[w]()
timers = []
callbacks = set()
for epoch, tid, callback in self.timers:
if epoch <= monotonic():
callback()
else:
timers.append((epoch, tid, callback))
callbacks.add(callback)
update_at = monotonic() + self.screen_update_delay
before = len(timers)
for w in self.iterwindows():
if w.screen.is_dirty() and w.update_screen not in callbacks:
timers.append((update_at, next(timer_id), w.update_screen))
if len(timers) > before:
timers.sort()
self.timers = timers
def on_window_resize(self, window, w, h):
# debounce resize events
self.pending_resize = [monotonic(), w, h]
self.call_after(0.02, self.apply_pending_resize)
def apply_pending_resize(self):
if self.pending_resize is None:
return
if monotonic() - self.pending_resize[0] < 0.02:
self.call_after(0.02, self.apply_pending_resize)
return
viewport_size.width, viewport_size.height = self.pending_resize[1:]
for tab in self.tabs:
tab.relayout()
self.pending_resize = None
self.resize_gl_viewport = True
glfw.glfwPostEmptyEvent()
@property
def active_tab(self):
return self.tabs[0] if self.tabs else None
def is_tab_visible(self, tab):
return self.active_tab is tab
@property
def active_window(self):
t = self.active_tab
if t is not None:
return t.active_window
def on_text_input(self, window, codepoint, mods):
data = interpret_text_event(codepoint, mods)
if data:
w = self.active_window
if w is not None:
w.write_to_child(data)
def on_key(self, window, key, scancode, action, mods):
if action == glfw_constants.GLFW_PRESS or action == glfw_constants.GLFW_REPEAT:
func = get_shortcut(self.opts.keymap, mods, key)
tab = self.active_tab
window = self.active_window
if func is not None:
func = getattr(self, func, getattr(tab, func, getattr(window, func, None)))
if func is not None:
passthrough = func()
if not passthrough:
return
if window:
if window.char_grid.scrolled_by:
window.scroll_end()
data = interpret_key_event(key, scancode, mods)
if data:
window.write_to_child(data)
def on_focus(self, window, focused):
w = self.active_window
if w is not None:
w.focus_changed(focused)
def on_mouse_button(self, window, button, action, mods):
if action == glfw_constants.GLFW_RELEASE:
if button == glfw_constants.GLFW_MOUSE_BUTTON_MIDDLE:
w = self.active_window
if w is not None:
w.paste_from_selection()
return
# GUI thread API {{{
def render(self):
if self.pending_resize:
return
if self.resize_gl_viewport:
glViewport(0, 0, viewport_size.width, viewport_size.height)
self.resize_gl_viewport = False
tab = self.active_tab
if tab is not None:
if tab.title != self.glfw_window_title:
self.glfw_window_title = tab.title
glfw.glfwSetWindowTitle(self.glfw_window, self.glfw_window_title.encode('utf-8'))
with self.sprites:
self.sprites.render_dirty_cells()
tab.render()
render_data = {window: window.char_grid.prepare_for_render(self.sprites) for window in tab.visible_windows()}
active = self.active_window
with self.cell_program:
for window, rd in render_data.items():
if rd is not None:
window.char_grid.render_cells(rd, self.cell_program, self.sprites)
rd = render_data.get(active)
if rd is not None:
with self.cursor_program:
active.char_grid.render_cursor(rd, self.cursor_program)
def destroy(self):
# Must be called in the main thread as it manipulates signal handlers
signal.signal(signal.SIGINT, signal.SIG_DFL)
signal.signal(signal.SIGTERM, signal.SIG_DFL)
self.shutting_down = True
wakeup()
self.join()
for t in self.tabs:
t.destroy()
del self.tabs
self.sprites.destroy()
del self.sprites
# }}}

View File

@ -3,6 +3,8 @@
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import re
import os
import signal
import subprocess
import ctypes
from collections import namedtuple
@ -249,3 +251,12 @@ def parse_color_set(raw):
yield c, r << 16 | g << 8 | b
except Exception:
continue
def handle_unix_signals():
read_fd, write_fd = os.pipe2(os.O_NONBLOCK | os.O_CLOEXEC)
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, lambda x, y: None)
signal.siginterrupt(sig, False)
signal.set_wakeup_fd(write_fd)
return read_fd

162
kitty/window.py Normal file
View File

@ -0,0 +1,162 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import os
import weakref
import subprocess
from functools import partial
import glfw
from .char_grid import CharGrid
from .constants import wakeup, tab_manager, appname, WindowGeometry
from .fast_data_types import (
BRACKETED_PASTE_START, BRACKETED_PASTE_END, Screen, read_bytes_dump, read_bytes
)
from .terminfo import get_capabilities
from .utils import sanitize_title
class Window:
def __init__(self, tab, child, opts, args):
self.tabref = weakref.ref(tab)
self.geometry = WindowGeometry(0, 0, 0, 0, 0, 0)
self.needs_layout = True
self.title = appname
self.is_visible_in_layout = True
self.child, self.opts = child, opts
self.child_fd = child.child_fd
self.screen = Screen(self, 24, 80, opts.scrollback_lines)
self.read_bytes = partial(read_bytes_dump, self.dump_commands) if args.dump_commands else read_bytes
self.draw_dump_buf = []
self.write_buf = memoryview(b'')
self.char_grid = CharGrid(self.screen, opts)
def refresh(self):
self.screen.mark_as_dirty()
wakeup()
def set_geometry(self, new_geometry):
if self.needs_layout or new_geometry.xnum != self.screen.columns or new_geometry.ynum != self.screen.lines:
self.screen.resize(new_geometry.ynum, new_geometry.xnum)
self.child.resize_pty(self.screen.columns, self.screen.lines)
self.char_grid.resize(new_geometry)
self.needs_layout = False
else:
self.char_grid.update_position(new_geometry)
self.geometry = new_geometry
def close(self):
tab_manager().close_window(self)
def destroy(self):
self.child.hangup()
self.child.get_child_status() # Ensure child does not become zombie
def read_ready(self):
if self.read_bytes(self.screen, self.child_fd) is False:
self.close() # EOF
def write_ready(self):
while self.write_buf:
try:
n = os.write(self.child_fd, self.write_buf)
except BlockingIOError:
n = 0
if not n:
return
self.write_buf = self.write_buf[n:]
def write_to_child(self, data):
self.write_buf = memoryview(self.write_buf.tobytes() + data)
wakeup()
def update_screen(self):
self.char_grid.update_cell_data()
glfw.glfwPostEmptyEvent()
def focus_changed(self, focused):
if focused:
if self.screen.focus_tracking_enabled():
self.write_to_child(b'\x1b[I')
else:
if self.screen.focus_tracking_enabled():
self.write_to_child(b'\x1b[O')
def title_changed(self, new_title):
self.title = sanitize_title(new_title or appname)
glfw.glfwPostEmptyEvent()
def icon_changed(self, new_icon):
pass # TODO: Implement this
def set_dynamic_color(self, code, value):
wmap = {10: 'fg', 11: 'bg', 110: 'fg', 111: 'bg'}
if isinstance(value, bytes):
value = value.decode('utf-8')
color_changes = {}
for val in value.split(';'):
w = wmap.get(code)
if w is not None:
if code >= 110:
val = None
color_changes[w] = val
code += 1
self.char_grid.change_colors(color_changes)
glfw.glfwPostEmptyEvent()
def request_capabilities(self, q):
self.write_to_child(get_capabilities(q))
# actions {{{
def paste(self, text):
if text:
if self.screen.in_bracketed_paste_mode():
text = BRACKETED_PASTE_START.encode('ascii') + text + BRACKETED_PASTE_END.encode('ascii')
self.write_to_child(text)
def paste_from_clipboard(self):
text = glfw.glfwGetClipboardString(self.window)
self.paste(text)
def paste_from_selection(self):
# glfw has no way to get the primary selection
# https://github.com/glfw/glfw/issues/894
text = subprocess.check_output(['xsel'])
self.paste(text)
def scroll_line_up(self):
self.queue_action(self.char_grid.scroll, 'line', True)
def scroll_line_down(self):
self.queue_action(self.char_grid.scroll, 'line', False)
def scroll_page_up(self):
self.queue_action(self.char_grid.scroll, 'page', True)
def scroll_page_down(self):
self.queue_action(self.char_grid.scroll, 'page', False)
def scroll_home(self):
self.queue_action(self.char_grid.scroll, 'full', True)
def scroll_end(self):
self.queue_action(self.char_grid.scroll, 'full', False)
# }}}
def dump_commands(self, *a):
if a:
if a[0] == 'draw':
if a[1] is None:
if self.draw_dump_buf:
print('draw', ''.join(self.draw_dump_buf))
self.draw_dump_buf = []
else:
self.draw_dump_buf.append(a[1])
else:
if self.draw_dump_buf:
print('draw', ''.join(self.draw_dump_buf))
self.draw_dump_buf = []
print(*a)