405 lines
17 KiB
Python
405 lines
17 KiB
Python
#!/usr/bin/env python
|
|
# vim:fileencoding=utf-8
|
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
|
|
|
import re
|
|
import sys
|
|
from collections import namedtuple
|
|
from ctypes import addressof, sizeof
|
|
from enum import Enum
|
|
|
|
from .config import build_ansi_color_table, defaults
|
|
from .constants import (
|
|
GLfloat, GLuint, ScreenGeometry, cell_size, viewport_size
|
|
)
|
|
from .fast_data_types import (
|
|
CURSOR_BEAM, CURSOR_BLOCK, CURSOR_UNDERLINE, DATA_CELL_SIZE, GL_BLEND,
|
|
GL_FLOAT, GL_LINE_LOOP, GL_TRIANGLE_FAN, GL_UNSIGNED_INT, glDisable,
|
|
glDrawArrays, glDrawArraysInstanced, glEnable, glUniform1i, glUniform2f,
|
|
glUniform2i, glUniform2ui, glUniform4f, glUniform4ui
|
|
)
|
|
from .rgb import to_color
|
|
from .shaders import ShaderProgram, load_shaders
|
|
from .utils import (
|
|
color_as_int, color_from_int, get_logical_dpi, open_url, safe_print,
|
|
set_primary_selection
|
|
)
|
|
|
|
Cursor = namedtuple('Cursor', 'x y shape blink')
|
|
|
|
|
|
class DynamicColor(Enum):
|
|
default_fg, default_bg, cursor_color, highlight_fg, highlight_bg = range(1, 6)
|
|
|
|
|
|
class CellProgram(ShaderProgram):
|
|
|
|
def __init__(self, *args):
|
|
ShaderProgram.__init__(self, *args)
|
|
self.color_table_buf = None
|
|
|
|
def send_color_table(self, color_profile):
|
|
if color_profile.ubo is None:
|
|
color_profile.ubo = self.init_uniform_block('ColorTable', 'color_table')
|
|
ubo = color_profile.ubo
|
|
if self.color_table_buf is None:
|
|
self.color_table_buf = (GLuint * (ubo.size // sizeof(GLuint)))()
|
|
offset = ubo.offsets['color_table'] // sizeof(GLuint)
|
|
stride = ubo.size // (256 * sizeof(GLuint))
|
|
color_profile.copy_color_table(addressof(self.color_table_buf), offset, stride)
|
|
self.send_uniform_buffer_data(ubo, self.color_table_buf)
|
|
|
|
def create_sprite_map(self):
|
|
with self.array_object_creator() as add_attribute:
|
|
stride = DATA_CELL_SIZE * sizeof(GLuint)
|
|
size = DATA_CELL_SIZE // 2
|
|
add_attribute('sprite_coords', size=size, dtype=GL_UNSIGNED_INT, stride=stride, divisor=1)
|
|
add_attribute('colors', size=size, dtype=GL_UNSIGNED_INT, stride=stride, offset=stride // 2, divisor=1)
|
|
add_attribute.newbuf()
|
|
add_attribute('is_selected', size=1, dtype=GL_FLOAT, stride=sizeof(GLfloat), divisor=1)
|
|
return add_attribute.vao_id
|
|
|
|
|
|
def load_shader_programs():
|
|
cell = CellProgram(*load_shaders('cell'))
|
|
cursor = ShaderProgram(*load_shaders('cursor'))
|
|
with cursor.array_object_creator() as add_attribute:
|
|
cursor.vao_id = add_attribute.vao_id
|
|
return cell, cursor
|
|
|
|
|
|
class Selection: # {{{
|
|
|
|
__slots__ = tuple('in_progress start_x start_y start_scrolled_by end_x end_y end_scrolled_by'.split())
|
|
|
|
def __init__(self):
|
|
self.clear()
|
|
|
|
def clear(self):
|
|
self.in_progress = False
|
|
self.start_x = self.start_y = self.end_x = self.end_y = 0
|
|
self.start_scrolled_by = self.end_scrolled_by = 0
|
|
|
|
def limits(self, scrolled_by, lines, columns):
|
|
|
|
def coord(x, y, ydelta):
|
|
y = y - ydelta + scrolled_by
|
|
if y < 0:
|
|
x, y = 0, 0
|
|
elif y >= lines:
|
|
x, y = columns - 1, lines - 1
|
|
return x, y
|
|
|
|
a = coord(self.start_x, self.start_y, self.start_scrolled_by)
|
|
b = coord(self.end_x, self.end_y, self.end_scrolled_by)
|
|
return (a, b) if a[1] < b[1] or (a[1] == b[1] and a[0] <= b[0]) else (b, a)
|
|
|
|
def text(self, linebuf, historybuf):
|
|
sy = self.start_y - self.start_scrolled_by
|
|
ey = self.end_y - self.end_scrolled_by
|
|
if sy == ey and self.start_x == self.end_x:
|
|
return ''
|
|
a, b = (sy, self.start_x), (ey, self.end_x)
|
|
if a > b:
|
|
a, b = b, a
|
|
|
|
def line(y):
|
|
if y < 0:
|
|
return historybuf.line(-1 - y)
|
|
return linebuf.line(y)
|
|
|
|
lines = []
|
|
for y in range(a[0], b[0] + 1):
|
|
startx, endx = 0, linebuf.xnum - 1
|
|
if y == a[0]:
|
|
startx = max(0, min(a[1], endx))
|
|
if y == b[0]:
|
|
endx = max(0, min(b[1], endx))
|
|
l = line(y)
|
|
is_continued = l.is_continued()
|
|
if endx - startx >= linebuf.xnum - 1:
|
|
l = str(l).rstrip(' ')
|
|
else:
|
|
l = ''.join(l[x] for x in range(startx, endx + 1))
|
|
if not is_continued and startx == 0 and len(lines) > 0:
|
|
l = '\n' + l
|
|
lines.append(l)
|
|
return ''.join(lines)
|
|
# }}}
|
|
|
|
|
|
def calculate_gl_geometry(window_geometry, viewport_width, viewport_height, cell_width, cell_height):
|
|
dx, dy = 2 * cell_width / viewport_width, 2 * cell_height / viewport_height
|
|
xmargin = window_geometry.left / viewport_width
|
|
ymargin = window_geometry.top / viewport_height
|
|
xstart = -1 + 2 * xmargin
|
|
ystart = 1 - 2 * ymargin
|
|
return ScreenGeometry(xstart, ystart, window_geometry.xnum, window_geometry.ynum, dx, dy)
|
|
|
|
|
|
def render_cells(vao_id, sg, cell_program, sprites, color_profile, invert_colors=False):
|
|
if color_profile.dirty:
|
|
cell_program.send_color_table(color_profile)
|
|
color_profile.dirty = False
|
|
ul = cell_program.uniform_location
|
|
glUniform2ui(ul('dimensions'), sg.xnum, sg.ynum)
|
|
glUniform4ui(ul('default_colors'), color_profile.default_fg, color_profile.default_bg, color_profile.highlight_fg, color_profile.highlight_bg)
|
|
glUniform2i(ul('color_indices'), 1 if invert_colors else 0, 0 if invert_colors else 1)
|
|
glUniform4f(ul('steps'), sg.xstart, sg.ystart, sg.dx, sg.dy)
|
|
glUniform1i(ul('sprites'), sprites.sampler_num)
|
|
glUniform2f(ul('sprite_layout'), *(sprites.layout))
|
|
with cell_program.bound_vertex_array(vao_id), cell_program.bound_uniform_buffer(color_profile.ubo):
|
|
glDrawArraysInstanced(GL_TRIANGLE_FAN, 0, 4, sg.xnum * sg.ynum)
|
|
|
|
|
|
class CharGrid:
|
|
|
|
url_pat = re.compile('(?:http|https|file|ftp)://\S+', re.IGNORECASE)
|
|
|
|
def __init__(self, screen, opts):
|
|
self.vao_id = None
|
|
self.current_selection = Selection()
|
|
self.scroll_changed = False
|
|
self.last_rendered_selection = None
|
|
self.render_data = None
|
|
self.scrolled_by = 0
|
|
self.data_buffer_size = 0
|
|
self.screen = screen
|
|
self.opts = opts
|
|
self.screen.color_profile.update_ansi_color_table(build_ansi_color_table(opts))
|
|
self.screen.color_profile.set_configured_colors(*map(color_as_int, (
|
|
opts.foreground, opts.background, opts.cursor, opts.selection_foreground, opts.selection_background)))
|
|
self.screen.color_profile.dirty = True
|
|
self.dpix, self.dpiy = get_logical_dpi()
|
|
self.opts = opts
|
|
self.default_cursor = self.current_cursor = Cursor(0, 0, opts.cursor_shape, opts.cursor_blink_interval > 0)
|
|
self.opts = opts
|
|
|
|
def escape(chars):
|
|
return ''.join(frozenset(chars)).replace('\\', r'\\').replace(']', r'\]').replace('-', r'\-')
|
|
|
|
try:
|
|
self.word_pat = re.compile(r'[\w{}]'.format(escape(self.opts.select_by_word_characters)), re.UNICODE)
|
|
except Exception:
|
|
safe_print('Invalid characters in select_by_word_characters, ignoring', file=sys.stderr)
|
|
self.word_pat = re.compile(r'[\w{}]'.format(escape(defaults.select_by_word_characters)), re.UNICODE)
|
|
|
|
def destroy(self, cell_program):
|
|
if self.vao_id is not None:
|
|
cell_program.remove_vertex_array(self.vao_id)
|
|
self.vao_id = None
|
|
|
|
def update_position(self, window_geometry):
|
|
self.screen_geometry = calculate_gl_geometry(window_geometry, viewport_size.width, viewport_size.height, cell_size.width, cell_size.height)
|
|
|
|
def resize(self, window_geometry):
|
|
self.update_position(window_geometry)
|
|
self.data_buffer_size = sizeof(GLuint) * self.screen_geometry.ynum * self.screen_geometry.xnum * DATA_CELL_SIZE
|
|
self.selection_buf = (GLfloat * (self.screen_geometry.ynum * self.screen_geometry.xnum))()
|
|
self.current_selection.clear()
|
|
|
|
def change_colors(self, changes):
|
|
dirtied = False
|
|
|
|
def item(raw):
|
|
if raw is None:
|
|
return 0
|
|
val = to_color(raw)
|
|
return None if val is None else (color_as_int(val) << 8) | 2
|
|
|
|
for which, val in changes.items():
|
|
val = item(val)
|
|
if val is None:
|
|
continue
|
|
dirtied = True
|
|
setattr(self.screen.color_profile, which.name, val)
|
|
if dirtied:
|
|
self.screen.mark_as_dirty()
|
|
|
|
def scroll(self, amt, upwards=True):
|
|
if not isinstance(amt, int):
|
|
amt = {'line': 1, 'page': self.screen.lines - 1, 'full': self.screen.historybuf.count}[amt]
|
|
if not upwards:
|
|
amt *= -1
|
|
y = max(0, min(self.scrolled_by + amt, self.screen.historybuf.count))
|
|
if y != self.scrolled_by:
|
|
self.scrolled_by = y
|
|
self.scroll_changed = True
|
|
|
|
def update_cell_data(self, cell_program, force_full_refresh=False):
|
|
if self.data_buffer_size == 0:
|
|
return
|
|
with cell_program.mapped_vertex_data(self.vao_id, self.data_buffer_size) as address:
|
|
cursor_changed, self.scrolled_by = self.screen.update_cell_data(
|
|
address, self.scrolled_by, force_full_refresh)
|
|
|
|
self.current_selection.clear()
|
|
self.render_data = self.screen_geometry
|
|
if cursor_changed:
|
|
c = self.screen.cursor
|
|
self.current_cursor = Cursor(c.x, c.y, c.shape, c.blink)
|
|
|
|
def cell_for_pos(self, x, y):
|
|
x, y = int(x // cell_size.width), int(y // cell_size.height)
|
|
if 0 <= x < self.screen.columns and 0 <= y < self.screen.lines:
|
|
return x, y
|
|
return None, None
|
|
|
|
def update_drag(self, is_press, mx, my):
|
|
x, y = self.cell_for_pos(mx, my)
|
|
if x is None:
|
|
x = 0 if mx <= cell_size.width else self.screen.columns - 1
|
|
y = 0 if my <= cell_size.height else self.screen.lines - 1
|
|
ps = None
|
|
if is_press:
|
|
self.current_selection.start_x = self.current_selection.end_x = x
|
|
self.current_selection.start_y = self.current_selection.end_y = y
|
|
self.current_selection.start_scrolled_by = self.current_selection.end_scrolled_by = self.scrolled_by
|
|
self.current_selection.in_progress = True
|
|
elif self.current_selection.in_progress:
|
|
self.current_selection.end_x = x
|
|
self.current_selection.end_y = y
|
|
self.current_selection.end_scrolled_by = self.scrolled_by
|
|
if is_press is False:
|
|
self.current_selection.in_progress = False
|
|
ps = self.text_for_selection()
|
|
if ps and ps.strip():
|
|
set_primary_selection(ps)
|
|
|
|
def has_url_at(self, x, y):
|
|
x, y = self.cell_for_pos(x, y)
|
|
if x is not None:
|
|
l = self.screen_line(y)
|
|
if l is not None:
|
|
text = l.as_base_text()
|
|
for m in self.url_pat.finditer(text):
|
|
if m.start() <= x < m.end():
|
|
return True
|
|
return False
|
|
|
|
def click_url(self, x, y):
|
|
x, y = self.cell_for_pos(x, y)
|
|
if x is not None:
|
|
l = self.screen_line(y)
|
|
if l is not None:
|
|
text = l.as_base_text()
|
|
for m in self.url_pat.finditer(text):
|
|
if m.start() <= x < m.end():
|
|
url = ''.join(l[i] for i in range(*m.span())).rstrip('.')
|
|
# Remove trailing "] and similar
|
|
url = re.sub(r'''["'][)}\]]$''', '', url)
|
|
# Remove closing trailing character if it is matched by it's
|
|
# corresponding opening character before the url
|
|
if m.start() > 0:
|
|
before = l[m.start() - 1]
|
|
closing = {'(': ')', '[': ']', '{': '}', '<': '>', '"': '"', "'": "'", '`': '`', '|': '|', ':': ':'}.get(before)
|
|
if closing is not None and url.endswith(closing):
|
|
url = url[:-1]
|
|
if url:
|
|
open_url(url, self.opts.open_url_with)
|
|
|
|
def screen_line(self, y):
|
|
' Return the Line object corresponding to the yth line on the rendered screen '
|
|
if y >= 0 and y < self.screen.lines:
|
|
if self.scrolled_by:
|
|
if y < self.scrolled_by:
|
|
return self.screen.historybuf.line(self.scrolled_by - 1 - y)
|
|
return self.screen.line(y - self.scrolled_by)
|
|
else:
|
|
return self.screen.line(y)
|
|
|
|
def multi_click(self, count, x, y):
|
|
x, y = self.cell_for_pos(x, y)
|
|
if x is not None:
|
|
line = self.screen_line(y)
|
|
if line is not None and count in (2, 3):
|
|
s = self.current_selection
|
|
s.start_scrolled_by = s.end_scrolled_by = self.scrolled_by
|
|
s.start_y = s.end_y = y
|
|
s.in_progress = False
|
|
if count == 3:
|
|
for i in range(self.screen.columns):
|
|
if line[i] != ' ':
|
|
s.start_x = i
|
|
break
|
|
else:
|
|
s.start_x = 0
|
|
for i in range(self.screen.columns):
|
|
c = self.screen.columns - 1 - i
|
|
if line[c] != ' ':
|
|
s.end_x = c
|
|
break
|
|
else:
|
|
s.end_x = self.screen.columns - 1
|
|
elif count == 2:
|
|
i = x
|
|
while i >= 0 and self.word_pat.match(line[i]) is not None:
|
|
i -= 1
|
|
s.start_x = i if i == x else i + 1
|
|
i = x
|
|
while i < self.screen.columns and self.word_pat.match(line[i]) is not None:
|
|
i += 1
|
|
s.end_x = i if i == x else i - 1
|
|
ps = self.text_for_selection()
|
|
if ps:
|
|
set_primary_selection(ps)
|
|
|
|
def get_scrollback_as_ansi(self):
|
|
ans = []
|
|
self.screen.historybuf.as_ansi(ans.append)
|
|
self.screen.linebuf.as_ansi(ans.append)
|
|
return ''.join(ans).encode('utf-8')
|
|
|
|
def text_for_selection(self, sel=None):
|
|
s = sel or self.current_selection
|
|
return s.text(self.screen.linebuf, self.screen.historybuf)
|
|
|
|
def prepare_for_render(self, cell_program):
|
|
if self.vao_id is None:
|
|
self.vao_id = cell_program.create_sprite_map()
|
|
if self.scroll_changed or self.screen.is_dirty:
|
|
self.update_cell_data(cell_program)
|
|
self.scroll_changed = False
|
|
sg = self.render_data
|
|
start, end = sel = self.current_selection.limits(self.scrolled_by, self.screen.lines, self.screen.columns)
|
|
selection_changed = sel != self.last_rendered_selection
|
|
if selection_changed:
|
|
self.screen.apply_selection(addressof(self.selection_buf), start[0], start[1], end[0], end[1], len(self.selection_buf))
|
|
cell_program.send_vertex_data(self.vao_id, self.selection_buf, bufnum=1)
|
|
self.last_rendered_selection = sel
|
|
return sg
|
|
|
|
def render_cells(self, sg, cell_program, sprites, invert_colors=False):
|
|
render_cells(self.vao_id, sg, cell_program, sprites, self.screen.color_profile, invert_colors=invert_colors)
|
|
|
|
def render_cursor(self, sg, cursor_program, is_focused):
|
|
cursor = self.current_cursor
|
|
if not self.screen.cursor_visible or self.scrolled_by:
|
|
return
|
|
|
|
def width(w=2, vert=True):
|
|
dpi = self.dpix if vert else self.dpiy
|
|
w *= dpi / 72.0 # as pixels
|
|
factor = 2 / (viewport_size.width if vert else viewport_size.height)
|
|
return w * factor
|
|
|
|
ul = cursor_program.uniform_location
|
|
left = sg.xstart + cursor.x * sg.dx
|
|
top = sg.ystart - cursor.y * sg.dy
|
|
col = color_from_int(self.screen.color_profile.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] / 255.0, col[1] / 255.0, col[2] / 255.0, alpha)
|
|
glUniform2f(ul('xpos'), left, right)
|
|
glUniform2f(ul('ypos'), top, bottom)
|
|
with cursor_program.bound_vertex_array(cursor_program.vao_id):
|
|
glDrawArrays(GL_TRIANGLE_FAN if is_focused else GL_LINE_LOOP, 0, 4)
|
|
glDisable(GL_BLEND)
|