Source code for eye.helpers.lexercolor

# this project is licensed under the WTFPLv2, see COPYING.txt for details

"""Color scheme application

This plugin allows to load color scheme definition from a file and apply them to editor and syntax coloring.

Color scheme format
===================

Color scheme files are in INI format (as read by `configparser`). A scheme file sets style attributes for multiples
lexers and such descriptions can be applied to an editor.

Sections
--------

A color scheme file consist in one or more INI sections, each corresponding to a lexer.
The special section `*` applies to all lexers. When applying a color scheme to an editor, the `*` section is applied
first if it exists, then the section for the lexer of the editor is applied, if it exists and if the editor has
a lexer.

Attributes
----------

Within a section, there are multiple entries in the form: `item_kind.item_name.style_property = style_value`.

The `item_kind.item_name` part specifies for which items of the lexer it should apply, for example:
the "keyword" token, the "search" indicator, or simply the caret style.

The `style_property` indicates what attribute should be set, for example: the font size, the background color.

Finally, the `style_value` is the value the `style_property` should be set to, for example: a color, a size, a
font name.

Item types
----------

Editor properties
+++++++++++++++++

If `item_kind` is `base`, `item_name` can be:

* `text`: applies to normal text
* `selection`': applies to selected text
* `whitespace`
* `caret`: applies to caret, for `foreground`, or whole line under cursor, for `background` (if set visible)
* `hotspot`
* `matchedbrace`: for a brace under cursor, which has a corresponding matching brace
* `unmatchedbrace`: for a brace under cursor, which doesn't have a corresponding matching brace
* `margin`: for the margin column

The valid `style_property` are `foreground`, `background`, `font`, `points`, `bold`, `italic`, `underline`.

Tokens
++++++

If `item_kind` is `token`, `item_name` is the token name, which corresponds to one style in `QsciLexer`.

The token name `*` is special: it matches all tokens, and is applied first, so it can be overwritten by more
specific color scheme entries.

The valid `style_property` are `foreground`, `background`, `font`, `points`, `bold`, `italic`, `underline`.

Styles
++++++

If `item_kind` is `style`, `item_name` is the style name, as registered in :any:`eye.helpers.styles`.

The valid `style_property` are `foreground`, `background`, `font`, `points`, `bold`, `italic`, `underline` and
`eolfill`.

Indicators
++++++++++

If `item_kind` is `indicator`, `item_name` is the indicator name (see :any:`eye.widgets.editor.Editor.indicators`)

The valid `style_property` are: `foreground`, `background` and `style`.

Property types
--------------

* `foreground` (or `fg`, or `color`) specifies the text color
* `background` (or `bg`) specifies the background color

* `font` specifies the font family (font name)
* `points` specifies the font size in points

* `bold` specifies whether the font is bold
* `italic` specifies whether the font is italic
* `underline` specifies whether the font is underline

* `eolfill` specifies whether the background color applies to the rest of the line, after last character

* `style` specifies which indicator style should be used, e.g. `StraightBoxIndicator`


Module contents
===============

"""

from abc import ABC, abstractmethod
from configparser import ConfigParser
from logging import getLogger

from eye.colorutils import QColorAlpha
from eye.connector import category_objects, register_signal, register_setup, disabled
from eye.helpers._lexercolorgroups import get_id_and_aliases
from eye.helpers.styles import STYLES
from eye.lexers import styles_from_lexer

__all__ = (
	'set_enabled', 'use_scheme_file', 'add_scheme_file',
	'lexer_set_font_family', 'lexer_set_font_point_size',
	'apply_scheme_to_editor', 'apply_scheme_dict_to_editor',
	'apply_scheme_on_lexer_change', 'apply_scheme_on_create',
)


LOGGER = getLogger(__name__)

FG_ATTRS = frozenset(['fg', 'foreground', 'color'])
BG_ATTRS = frozenset(['bg', 'background'])

SCHEME = None


def new_scheme():
	parser = ConfigParser()
	parser.optionxform = str
	return parser


def fuzzy_equals(a, b):
	def norm(name):
		return name.lower().replace(' ', '_')
	return norm(a) == norm(b)


def get_style_by_desc(lexer, desc, fuzzy=False):
	for i in range(1 << lexer.styleBitsNeeded()):
		idesc = lexer.description(i)
		if idesc == desc or (fuzzy and fuzzy_equals(desc, idesc)):
			return i


def parse_bool(s):
	s = s.lower()
	if s in ['1', 'true', 'yes', 'y', 'on']:
		return True
	elif s in ['0', 'false', 'no', 'n', 'off']:
		return False
	raise ValueError('%r is not a boolean value' % s)


class UnsupportedModification(Exception):
	pass


class Modificator(ABC):
	def __init__(self, editor, key, strvalue):
		self.editor = editor
		self.key = key
		self.strvalue = strvalue

	@abstractmethod
	def set_font(self, attr, value, *args):
		...

	@abstractmethod
	def set_color(self, value, *args):
		...

	@abstractmethod
	def set_paper(self, value, *args):
		...

	def apply_generic(self, attr, *args):
		if attr == 'font':
			self.set_font('Family', self.strvalue, *args)
		elif attr == 'points':
			self.set_font('PointSizeF', float(self.strvalue), *args)
		elif attr == 'bold':
			self.set_font('Bold', parse_bool(self.strvalue), *args)
		elif attr == 'italic':
			self.set_font('Italic', parse_bool(self.strvalue), *args)
		elif attr == 'underline':
			self.set_font('Underline', parse_bool(self.strvalue), *args)
		elif attr in FG_ATTRS:
			self.set_color(QColorAlpha(self.strvalue), *args)
		elif attr in BG_ATTRS:
			self.set_paper(QColorAlpha(self.strvalue), *args)
		else:
			raise UnsupportedModification()


class LexerModificator(Modificator):
	def apply(self):
		tokenname, attr = self.key.split('.')

		lexer = self.editor.lexer()
		if not lexer:
			return

		if tokenname == '*':
			ids = styles_from_lexer(lexer).values()
		elif tokenname == '_default':
			ids = [self.editor.STYLE_DEFAULT]
		else:
			ids = get_id_and_aliases(lexer, tokenname)

		for id in ids:
			self.apply_one(id, attr)

	def apply_one(self, style_id, attr):
		lexer = self.editor.lexer()

		self.apply_generic(attr, lexer, style_id)

	def apply_generic(self, attr, lexer, style_id):
		if attr == 'eolfill':
			lexer.setEolFill(parse_bool(self.strvalue))
		else:
			super().apply_generic(attr, lexer, style_id)

	def set_color(self, qc, lexer, style_id):
		lexer.setColor(QColorAlpha(self.strvalue), style_id)

	def set_paper(self, qc, lexer, style_id):
		lexer.setPaper(QColorAlpha(self.strvalue), style_id)

	def set_font(self, font_attr, value, lexer, style_id):
		font = lexer.font(style_id)
		font_attr = 'set%s' % font_attr
		getattr(font, font_attr)(value)
		lexer.setFont(font, style_id)


class EditorModificator(Modificator):
	def apply(self):
		element, attr = self.key.split('.')
		self.apply_generic(attr, element)

	def apply_caret(self, attr, strvalue):
		if attr in FG_ATTRS:
			qc = QColorAlpha(strvalue)
			self.editor.setCaretForegroundColor(qc)
		elif attr in BG_ATTRS:
			qc = QColorAlpha(strvalue)
			self.editor.setCaretLineBackgroundColor(qc)
		else:
			raise UnsupportedModification('only elements in %s are supported for caret' % (FG_ATTRS + BG_ATTRS))

	def set_color(self, qc, element):
		attrs = {
			'text': 'setColor',
			'selection': 'setSelectionForegroundColor',
			'whitespace': 'setWhitespaceForegroundColor',
			'caret': 'setCaretForegroundColor',
			'hotspot': 'setHotspotForegroundColor',
			'matchedbrace': 'setMatchedBraceForegroundColor',
			'unmatchedbrace': 'setUnmatchedBraceForegroundColor',
			'margin': 'setMarginsForegroundColor',
		}

		getattr(self.editor, attrs[element])(qc)

	def set_paper(self, qc, element):
		attrs = {
			'text': 'setPaper',
			'selection': 'setSelectionBackgroundColor',
			'whitespace': 'setWhitespaceBackgroundColor',
			'caret': 'setCaretLineBackgroundColor',
			'hotspot': 'setHotspotBackgroundColor',
			'matchedbrace': 'setMatchedBraceBackgroundColor',
			'unmatchedbrace': 'setUnmatchedBraceBackgroundColor',
			'margin': 'setMarginsBackgroundColor',
		}

		getattr(self.editor, attrs[element])(qc)

	def set_font(self, font_attr, value, element):
		if element != 'text':
			raise UnsupportedModification('only "text" is supported')
		# margin.* lacks a Editor.marginsFont()

		font = self.editor.font()
		font_attr = 'set%s' % font_attr
		getattr(font, font_attr)(value)
		self.editor.setFont(font)


class IndicatorModificator(Modificator):
	def apply(self):
		name, attr = self.key.split('.')

		indicator = self.editor.indicators.get(name)
		if not indicator:
			indicator = self.editor.create_indicator(name, self.editor.PlainIndicator)

		self.apply_generic(attr, indicator)

	def apply_generic(self, attr, indicator):
		if attr == 'style':
			indicator.set_style(getattr(self.editor, self.strvalue))
		else:
			super().apply_generic(attr, indicator)

	def set_color(self, qc, indicator):
		indicator.set_color(qc)

	def set_paper(self, qc, indicator):
		indicator.setOutlineColor(qc)

	def set_font(self, *args):
		raise UnsupportedModification('font cannot be set for indicators')


class StyleModificator(Modificator):
	def apply(self):
		name, attr = self.key.split('.')

		self.apply_generic(attr, STYLES[name])

	def apply_generic(self, attr, style):
		if attr == 'eolfill':
			style.setEolFill(parse_bool(self.strvalue))
		else:
			super().apply_generic(attr, style)

	def set_font(self, font_attr, value, style):
		font = style.font()
		font_attr = 'set%s' % font_attr
		getattr(font, font_attr)(value)
		style.setFont(font)

	def set_color(self, qc, style):
		style.set_color(qc)

	def set_paper(self, qc, style):
		style.set_paper(qc)


def get_modificator(name):
	modificators = {
		'token': LexerModificator,
		'indicator': IndicatorModificator,
		'base': EditorModificator,
		'style': StyleModificator,
	}

	return modificators.get(name)


[docs] def apply_scheme_dict_to_editor(dct, editor): for key in sorted(dct): value = dct[key] try: styletype, subkey = key.split('.', 1) except ValueError: LOGGER.info('ignoring malformed style key %r', key) continue modificator_type = get_modificator(styletype) if modificator_type is None: LOGGER.info('ignoring unknown style type %r', styletype) continue mod = modificator_type(editor, subkey, value) try: mod.apply() except UnsupportedModification as exc: LOGGER.warning('%s is not supported: %s', key, exc) continue LOGGER.debug('applied %r=%r to %r', key, value, editor)
[docs] def apply_scheme_to_editor(parser, editor): lexer = editor.lexer() lexer_name = lexer.language() if lexer else 'None' for section in ('*', lexer_name): if parser.has_section(section): LOGGER.debug('using section %r for file %r', section, editor.path) dict_scheme = dict(parser.items(section)) apply_scheme_dict_to_editor(dict_scheme, editor)
[docs] def use_scheme_file(path, apply_to_all=True): """Use a color scheme file Reset current color scheme and load a new scheme file. :param path: color scheme file :param applyToAll: if True, apply to existing editor widgets """ global SCHEME if path is None: SCHEME = None LOGGER.info('unsetting scheme file') return SCHEME = None add_scheme_file(path, apply_to_all)
[docs] def add_scheme_file(path, apply_to_all=True): """Load a color scheme file Unlike :any:`use_scheme_file`, it does not reset the current color scheme but adds new definitions. If a previous scheme file had some definitions which are redefined in the new scheme, they are replaced by the new one. :param path: color scheme file :param apply_to_all: if True, apply to existing editor widgets """ global SCHEME if SCHEME is None: LOGGER.info('starting with empty scheme') SCHEME = new_scheme() LOGGER.info('adding scheme %r', path) SCHEME.read([path]) if apply_to_all: for ed in category_objects('editor'): apply_scheme_to_editor(SCHEME, ed)
[docs] @register_setup('editor') @disabled def apply_scheme_on_create(editor): if SCHEME: apply_scheme_to_editor(SCHEME, editor)
[docs] @register_signal('editor', 'lexer_changed') @disabled def apply_scheme_on_lexer_change(editor, lexer): if SCHEME: apply_scheme_to_editor(SCHEME, editor)
[docs] def set_enabled(enabled=True): """Enabled/disable automatic color scheme application to editors""" apply_scheme_on_create.enabled = enabled apply_scheme_on_lexer_change.enabled = enabled
def _lexer_set_font(lexer, cb, style): if style >= 0: font = lexer.font(style) cb(font) lexer.setFont(font, style) return for i in range(1 << lexer.styleBitsNeeded()): desc = lexer.description(i) if desc: _lexer_set_font(lexer, cb, i)
[docs] def lexer_set_font_family(lexer, family, style=-1): """Set the font family of a lexer Set just the font family for `style` of `lexer`. Like :any:`QsciLexer.setFont`, but only change the font family, not font size or weight. :param lexer: the lexer to change :type lexer: QsciLexer :param family: font family to use :param style: if negative, modifies all styles of `lexer` """ def cb(font): font.setFamily(family) _lexer_set_font(lexer, cb, style)
[docs] def lexer_set_font_point_size(lexer, size, style=-1): """Set the font size of a lexer Set just the font size for `style` of `lexer`. Like :any:`QsciLexer.setFont`, but only change the font size, not font family or weight. :param lexer: the lexer to change :type lexer: QsciLexer :param size: font size to use (in points) :param style: if negative, modifies all styles of `lexer` """ def cb(font): font.setPointSize(size) _lexer_set_font(lexer, cb, style)