diff --git a/docs/conf.py b/docs/conf.py index aac3895be..ec58badc4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,9 +45,9 @@ # built documents. # # The short X.Y version. -version = '1.2.9' +version = '1.3.1' # The full version, including alpha/beta/rc tags. -release = '1.2.9' +release = '1.3.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/python_toolbox/__init__.py b/python_toolbox/__init__.py index 8863ab4b4..c5262afae 100644 --- a/python_toolbox/__init__.py +++ b/python_toolbox/__init__.py @@ -13,7 +13,7 @@ import python_toolbox.version_info -__version__ = '1.2.9' +__version__ = '1.3.2' __version_info__ = python_toolbox.version_info.VersionInfo( *(map(int, __version__.split('.'))) ) diff --git a/python_toolbox/poshing.py b/python_toolbox/poshing.py index 197f4a3e5..b9a73c0d2 100644 --- a/python_toolbox/poshing.py +++ b/python_toolbox/poshing.py @@ -24,28 +24,113 @@ def format_envvar(x: str) -> str: return '~' if x == 'HOME' else f'${x}' -def load_envvar_paths() -> dict: +def expand_envvars(s: str) -> str: + """Expand environment variables in a string. Supports $VAR and ${VAR} syntax.""" + result = s + + # Handle ${VAR} syntax + result = re.sub( + r'\$\{([^}]+)\}', + lambda m: os.environ.get(m.group(1), m.group(0)), + result + ) + + # Handle $VAR syntax (variable name ends at non-alphanumeric/underscore) + result = re.sub( + r'\$([A-Za-z_][A-Za-z0-9_]*)', + lambda m: os.environ.get(m.group(1), m.group(0)), + result + ) + + return result + + +def ensure_windows_path_string(path_string: str) -> str: + """Convert Linux/POSIX-style paths to Windows format.""" + # Handle file:/// URLs + if path_string.startswith('file:///'): + return urllib.parse.unquote(path_string[8:]) + + # Return other URLs (like http://) unaltered + if re.match(r'^[a-zA-Z]+://', path_string): + return path_string + + path = pathlib.Path(path_string) + posix_path = path.as_posix() + if re.match('^/[a-zA-Z]/.*$', posix_path): + # Handle local drive paths like /c/Users/... + return '%s:%s' % ( + posix_path[1], + re.sub('(?<=[^\\\\])\\\\ ', ' ', posix_path).replace('/', '\\')[2:] + ) + elif re.match('^//[^/]+/.*$', posix_path): + # Handle UNC network paths like //server/share/... + return posix_path.replace('/', '\\') + else: + return path_string + + +def normalize_path_separators(s: str) -> str: + """Normalize path separators for the current OS.""" + if sys.platform == 'win32': + return ensure_windows_path_string(s) + else: + return ensure_linux_path_string(s) + + +def ensure_linux_path_string(path_string: str) -> str: + """Convert Windows-style paths to Linux/POSIX format.""" + # Handle file:/// URLs + if path_string.startswith('file:///'): + return urllib.parse.unquote(path_string[8:]) + + # Return other URLs (like http://) unaltered + if re.match(r'^[a-zA-Z]+://', path_string): + return path_string + + # Convert backslashes to forward slashes + result = path_string.replace('\\', '/') + + # Handle Windows drive paths like C:/Users/... -> /c/Users/... + if re.match(r'^[a-zA-Z]:/', result): + result = '/' + result[0].lower() + result[2:] + + # Handle UNC paths like //server/share -> //server/share (already correct) + + return result + + +def load_config() -> tuple[dict, int]: """ - Load environment variable paths from ~/.posh/envvars.json. - Returns an empty dict if the file doesn't exist or is empty. + Load configuration from ~/.posh/config.json. + Returns (envvar_paths_dict, shawty_length_threshold). Expected format: { - "ENVVAR_NAME": ["path1", "path2", ...], - ... + "envvars": { + "ENVVAR_NAME": ["path1", "path2", ...], + ... + }, + "shawty_length_threshold": 30 } """ - config_path = pathlib.Path.home() / '.posh' / 'envvars.json' + config_path = pathlib.Path.home() / '.posh' / 'config.json' if not config_path.exists(): - return {} + return {}, 30 try: with open(config_path, 'r') as f: data = json.load(f) - return data if isinstance(data, dict) else {} + if not isinstance(data, dict): + return {}, 30 + + envvars = data.get('envvars', {}) + threshold = data.get('shawty_length_threshold', 30) + + return (envvars if isinstance(envvars, dict) else {}), threshold except (json.JSONDecodeError, IOError): - return {} + return {}, 30 def _posh(path_string: str = None, allow_cwd: bool = True) -> str: @@ -70,13 +155,16 @@ def _posh(path_string: str = None, allow_cwd: bool = True) -> str: # Load envvar paths from config file - envvar_paths = load_envvar_paths() + envvar_paths, _ = load_config() - # Convert string paths to pathlib.Path objects + # Convert string paths to pathlib.Path objects (with envvar expansion and separator normalization) for envvar_name in list(envvar_paths.keys()): if not isinstance(envvar_paths[envvar_name], list): envvar_paths[envvar_name] = [] - envvar_paths[envvar_name] = [pathlib.Path(p) for p in envvar_paths[envvar_name]] + envvar_paths[envvar_name] = [ + pathlib.Path(normalize_path_separators(expand_envvars(p))) + for p in envvar_paths[envvar_name] + ] # Add environment values if they exist for envvar_name in envvar_paths: @@ -102,12 +190,22 @@ def _posh(path_string: str = None, allow_cwd: bool = True) -> str: def apply_shawty(path_string: str, shawty_length_threshold: int = 30) -> str: """Apply shawty abbreviation to a path string.""" + starts_with_slash = path_string.startswith('/') slash_count = path_string.count('/') - if slash_count < 2: + + # Adjust count if path starts with slash (leading slash doesn't count) + adjusted_count = slash_count - 1 if starts_with_slash else slash_count + + if adjusted_count < 2: return path_string # Find first and last slash positions - first_slash = path_string.index('/') + if starts_with_slash: + # Skip the leading slash, find the second slash + first_slash = path_string.index('/', 1) + else: + first_slash = path_string.index('/') + last_slash = path_string.rindex('/') # Build abbreviated path (without slashes around ellipsis) @@ -125,7 +223,7 @@ def posh(path_strings: Iterable[str] | str | None = None, separator: str = SEPARATOR_NEWLINE, allow_cwd: bool = True, shawty: bool = False, - shawty_length_threshold: int = 30) -> str: + shawty_length_threshold: int | None = None) -> str: """ Convert paths to a more readable format using environment variables. @@ -135,7 +233,7 @@ def posh(path_strings: Iterable[str] | str | None = None, separator: Separator to use between multiple paths (SEPARATOR_NEWLINE or SEPARATOR_SPACE) allow_cwd: When False, don't resolve relative paths against current working directory shawty: Abbreviate paths with 2+ slashes: replace middle sections with ellipsis - shawty_length_threshold: If abbreviated path still exceeds this length, trim further + shawty_length_threshold: If abbreviated path still exceeds this length, trim further (defaults to config.json value) Returns: Formatted path string(s) @@ -146,10 +244,14 @@ def posh(path_strings: Iterable[str] | str | None = None, if not isinstance(path_strings, (list, tuple)): path_strings = [path_strings] + # Load config to get default threshold if not provided + _, config_threshold = load_config() + threshold = shawty_length_threshold if shawty_length_threshold is not None else config_threshold + results = [_posh(path_string, allow_cwd=allow_cwd) for path_string in path_strings] if shawty: - results = [apply_shawty(result, shawty_length_threshold) for result in results] + results = [apply_shawty(result, threshold) for result in results] if quote_mode == QUOTE_ALWAYS: quoted_results = [f'"{result}"' for result in results] @@ -167,31 +269,6 @@ def posh(path_strings: Iterable[str] | str | None = None, return sep.join(quoted_results) -def ensure_windows_path_string(path_string: str) -> str: - # Handle file:/// URLs - if path_string.startswith('file:///'): - # Strip the file:/// prefix and decode URL encoding - return urllib.parse.unquote(path_string[8:]) - - # Return other URLs (like http://) unaltered - if re.match(r'^[a-zA-Z]+://', path_string): - return path_string - - path = pathlib.Path(path_string) - posix_path = path.as_posix() - if re.match('^/[a-zA-Z]/.*$', posix_path): - # Handle local drive paths like /c/Users/... - return '%s:%s' % ( - posix_path[1], - re.sub('(?<=[^\\\\])\\\\ ', ' ', posix_path).replace('/', '\\')[2:] - ) - elif re.match('^//[^/]+/.*$', posix_path): - # Handle UNC network paths like //server/share/... - return posix_path.replace('/', '\\') - else: - return path_string - - def posh_path(path: pathlib.Path | str, allow_cwd: bool = True) -> str: """Process a path using the posh function directly.""" path_str = str(path) diff --git a/python_toolbox/wx_tools/__init__.py b/python_toolbox/wx_tools/__init__.py index 0f7ecf5cd..be4990d4f 100644 --- a/python_toolbox/wx_tools/__init__.py +++ b/python_toolbox/wx_tools/__init__.py @@ -14,7 +14,6 @@ from . import colors from . import keyboard from . import window_tools -from . import bitmap_tools from . import cursors from . import event_tools from . import generic_bitmaps diff --git a/python_toolbox/wx_tools/bitmap_tools.py b/python_toolbox/wx_tools/bitmap_tools.py deleted file mode 100644 index de515f8b6..000000000 --- a/python_toolbox/wx_tools/bitmap_tools.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2009-2017 Ram Rachum. -# This program is distributed under the MIT license. - -'''Defines bitmap-related tools.''' - -import pkg_resources -import wx - - -def color_replaced_bitmap(bitmap, old_rgb, new_rgb): - '''Replace all appearances of `old_rgb` with `new_rgb` in `bitmap`.''' - old_r, old_g, old_b = old_rgb - new_r, new_g, new_b = new_rgb - image = wx.ImageFromBitmap(bitmap) - assert isinstance(image, wx.Image) - image.Replace(old_r, old_g, old_b, new_r, new_g, new_b) - return wx.BitmapFromImage(image) - - -def bitmap_from_pkg_resources(package_or_requirement, resource_name): - ''' - Get a bitmap from a file using `pkg_resources`. - - Example: - - my_bitmap = bitmap_from_pkg_resources('whatever.images', 'image.jpg') - - ''' - return wx.Bitmap( - wx.Image( - pkg_resources.resource_stream(package_or_requirement, - resource_name), - wx.BITMAP_TYPE_ANY - ) - ) \ No newline at end of file diff --git a/python_toolbox/wx_tools/cursors/__init__.py b/python_toolbox/wx_tools/cursors/__init__.py index 4dc84716c..239f264b2 100644 --- a/python_toolbox/wx_tools/cursors/__init__.py +++ b/python_toolbox/wx_tools/cursors/__init__.py @@ -3,5 +3,4 @@ '''Defines various cursor-related tools.''' -from . import collection from .cursor_changer import CursorChanger \ No newline at end of file diff --git a/python_toolbox/wx_tools/cursors/collection/__init__.py b/python_toolbox/wx_tools/cursors/collection/__init__.py deleted file mode 100644 index a0b160f79..000000000 --- a/python_toolbox/wx_tools/cursors/collection/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright 2009-2017 Ram Rachum. -# This program is distributed under the MIT license. - -'''A collection of cursors.''' - -from .collection import get_open_grab, get_closed_grab \ No newline at end of file diff --git a/python_toolbox/wx_tools/cursors/collection/collection.py b/python_toolbox/wx_tools/cursors/collection/collection.py deleted file mode 100644 index 9447dd30c..000000000 --- a/python_toolbox/wx_tools/cursors/collection/collection.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright 2009-2017 Ram Rachum. -# This program is distributed under the MIT license. - -'''A collection of cursors.''' - -import pkg_resources -import wx - -from python_toolbox import caching - - -from . import images as __images_package -images_package = __images_package.__name__ - - -@caching.cache() -def get_open_grab(): - '''Get the "open grab" cursor.''' - file_name = 'open_grab.png' - hotspot = (8, 8) - stream = pkg_resources.resource_stream(images_package, - file_name) - image = wx.ImageFromStream(stream, wx.BITMAP_TYPE_ANY) - - if hotspot is not None: - image.SetOptionInt(wx.IMAGE_OPTION_CUR_HOTSPOT_X, hotspot[0]) - image.SetOptionInt(wx.IMAGE_OPTION_CUR_HOTSPOT_Y, hotspot[1]) - - cursor = wx.CursorFromImage(image) - return cursor - - -@caching.cache() -def get_closed_grab(): - '''Get the "closed grab" cursor.''' - file_name = 'closed_grab.png' - hotspot = (8, 8) - stream = pkg_resources.resource_stream(images_package, - file_name) - image = wx.ImageFromStream(stream, wx.BITMAP_TYPE_ANY) - - if hotspot is not None: - image.SetOptionInt(wx.IMAGE_OPTION_CUR_HOTSPOT_X, hotspot[0]) - image.SetOptionInt(wx.IMAGE_OPTION_CUR_HOTSPOT_Y, hotspot[1]) - - cursor = wx.CursorFromImage(image) - return cursor diff --git a/python_toolbox/wx_tools/cursors/collection/images/__init__.py b/python_toolbox/wx_tools/cursors/collection/images/__init__.py deleted file mode 100644 index 41546a512..000000000 --- a/python_toolbox/wx_tools/cursors/collection/images/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2009-2017 Ram Rachum. -# This program is distributed under the MIT license. - -'''Images package.''' diff --git a/python_toolbox/wx_tools/cursors/collection/images/closed_grab.png b/python_toolbox/wx_tools/cursors/collection/images/closed_grab.png deleted file mode 100644 index 3e3262c59..000000000 Binary files a/python_toolbox/wx_tools/cursors/collection/images/closed_grab.png and /dev/null differ diff --git a/python_toolbox/wx_tools/cursors/collection/images/open_grab.png b/python_toolbox/wx_tools/cursors/collection/images/open_grab.png deleted file mode 100644 index 3e051cbc8..000000000 Binary files a/python_toolbox/wx_tools/cursors/collection/images/open_grab.png and /dev/null differ diff --git a/python_toolbox/wx_tools/widgets/knob/__init__.py b/python_toolbox/wx_tools/widgets/knob/__init__.py deleted file mode 100644 index 251a9f44e..000000000 --- a/python_toolbox/wx_tools/widgets/knob/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright 2009-2011 Ram Rachum. -# This program is distributed under the LGPL2.1 license. - -''' -Defines the `Knob` class. - -See its documentation for more info. -''' - -from .knob import Knob \ No newline at end of file diff --git a/python_toolbox/wx_tools/widgets/knob/images/__init__.py b/python_toolbox/wx_tools/widgets/knob/images/__init__.py deleted file mode 100644 index 212989a2f..000000000 --- a/python_toolbox/wx_tools/widgets/knob/images/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2009-2011 Ram Rachum. -# This program is distributed under the LGPL2.1 license. - -'''Images package.''' \ No newline at end of file diff --git a/python_toolbox/wx_tools/widgets/knob/images/knob.png b/python_toolbox/wx_tools/widgets/knob/images/knob.png deleted file mode 100644 index fb2ed1e5b..000000000 Binary files a/python_toolbox/wx_tools/widgets/knob/images/knob.png and /dev/null differ diff --git a/python_toolbox/wx_tools/widgets/knob/knob.py b/python_toolbox/wx_tools/widgets/knob/knob.py deleted file mode 100644 index 475adf502..000000000 --- a/python_toolbox/wx_tools/widgets/knob/knob.py +++ /dev/null @@ -1,287 +0,0 @@ -# Copyright 2009-2011 Ram Rachum. -# This program is distributed under the LGPL2.1 license. - -''' -Defines the `Knob` class. - -See its documentation for more info. -''' - -from __future__ import division - -import math - -import wx -import pkg_resources - -from python_toolbox import math_tools -from python_toolbox import wx_tools -from python_toolbox import binary_search -from python_toolbox import cute_iter_tools -from python_toolbox.wx_tools.widgets.cute_panel import CutePanel - -from .snap_map import SnapMap - -from . import images as __images_package -images_package = __images_package.__name__ - - -class Knob(CutePanel): - ''' - A knob that sets a real value between `-infinity` and `infinity`. - - (Not really touching infinity.) - - By turning the knob with the mouse, the user changes a floating point - variable. - - - There are three "scales" that one should keep in mind when working with - Knob: - - 1. The "value" scale, which is the value that the actual final variable - gets. It spans from `-infinity` to `infinity`. - - 2. The "angle" scale, which is the angle in which the knob appears on - the screen. It spans from `(-(5/6) * pi)` to `((5/6) * pi)`. - - 3. As a more convenient mediator between them there's the "ratio" scale, - which spans from `-1` to `1`, and is mapped linearly to "angle". - - - The knob has snap points that can be modified with `.set_snap_point` and - `.remove_snap_point`. These are specified by value. - ''' - # todo future: make key that disables snapping while dragging - # todo: consider letting the knob turn just a bit slower near the edges. - # todo: currently forcing size to be constant, in future allow changing - def __init__(self, parent, getter, setter, *args, **kwargs): - ''' - Construct the knob. - - `getter` is the getter function used to get the value of the variable. - `setter` is the setter function used to set the value of the variable. - - Note that you can't give a size argument to knob, it is always created - with a size of (29, 29). - ''' - - assert 'size' not in kwargs - kwargs['size'] = (29, 29) - - assert callable(setter) and callable(getter) - self.value_getter, self.value_setter = getter, setter - - CutePanel.__init__(self, parent, *args, **kwargs) - - self.SetBackgroundStyle(wx.BG_STYLE_CUSTOM) - - self.original_bitmap = wx_tools.bitmap_tools.bitmap_from_pkg_resources( - images_package, - 'knob.png' - ) - - self.bind_event_handlers(Knob) - - self.SetCursor(wx_tools.cursors.collection.get_open_grab()) - - - self._knob_house_brush = wx.Brush(wx.Colour(0, 0, 0)) - '''Brush used to paint the circle around the knob.''' - - self.current_angle = 0 - '''The current angle of the knob.''' - - self.current_ratio = 0 - '''The current ratio of the knob.''' - - self.sensitivity = 25 - ''' - The knob's sensitivity. - - Higher values will cause faster changes in value when turning the knob. - ''' - - self.angle_resolution = math.pi / 180 - '''The minimal change in angle that will warrant a repaint.''' - - self.snap_points = [] - '''An ordered list of snap points, specified by value.''' - - self.base_drag_radius = 50 - ''' - The base drag radius, in pixels. - - This number is the basis for calculating the height of the area in which - the user can play with the mouse to turn the knob. Beyond that area the - knob will be turned all the way to one side, and any movement farther - will have no effect. - - If there are no snap points, the total height of that area will be `2 * - self.base_drag_radius`. - ''' - - self.snap_point_drag_well = 20 - ''' - The height of a snap point's drag well, in pixels. - - This is the height of the area on the screen in which, when the user - drags to it, the knob will have the value of the snap point. - - The bigger this is, the harder the snap point "traps" the mouse. - ''' - - self.being_dragged = False - '''Flag saying whether the knob is currently being dragged.''' - - self.snap_map = None - ''' - The current snap map used by the knob. - - See documentation of SnapMap for more info. - ''' - - self.needs_recalculation_flag = True - '''Flag saying whether the knob needs to be recalculated.''' - - self._recalculate() - - - def _angle_to_ratio(self, angle): - '''Convert from angle to ratio.''' - return angle / (math.pi * 5 / 6) - - def _ratio_to_value(self, ratio): - '''Convert from ratio to value.''' - return self.sensitivity * \ - math_tools.get_sign(ratio) * \ - (4 / math.pi**2) * \ - math.log(math.cos(ratio * math.pi / 2))**2 - - def _value_to_ratio(self, value): - '''Convert from value to ratio.''' - return math_tools.get_sign(value) * \ - (2 / math.pi) * \ - math.acos( - math.exp( - - (math.pi * math.sqrt(abs(value))) / \ - (2 * math.sqrt(self.sensitivity)) - ) - ) - - def _ratio_to_angle(self, ratio): - '''Convert from ratio to angle.''' - return ratio * (math.pi * 5 / 6) - - def _get_snap_points_as_ratios(self): - '''Get the list of snap points, but as ratios instead of as values.''' - return [self._value_to_ratio(value) for value in self.snap_points] - - def set_snap_point(self, value): - '''Set a snap point. Specified as value.''' - # Not optimizing with the sorting for now - self.snap_points.append(value) - self.snap_points.sort() - - def remove_snap_point(self, value): - '''Remove a snap point. Specified as value.''' - self.snap_points.remove(value) - - def _recalculate(self): - ''' - Recalculate the knob, changing its angle and refreshing if necessary. - ''' - value = self.value_getter() - self.current_ratio = self._value_to_ratio(value) - angle = self._ratio_to_angle(self.current_ratio) - d_angle = angle - self.current_angle - if abs(d_angle) > self.angle_resolution: - self.current_angle = angle - self.Refresh() - self.needs_recalculation_flag = False - - def _on_paint(self, event): - '''EVT_PAINT handler.''' - - # Not checking for recalculation flag, this widget is not real-time - # enough to care about the delay. - - dc = wx.BufferedPaintDC(self) - - dc.SetBackground(wx_tools.colors.get_background_brush()) - dc.Clear() - - w, h = self.GetClientSize() - - gc = wx.GraphicsContext.Create(dc) - - gc.SetPen(wx.TRANSPARENT_PEN) - gc.SetBrush(self._knob_house_brush) - - assert isinstance(gc, wx.GraphicsContext) - gc.Translate(w/2, h/2) - gc.Rotate(self.current_angle) - gc.DrawEllipse(-13.5, -13.5, 27, 27) - gc.DrawBitmap(self.original_bitmap, -13, -13, 26, 26) - - #gc.DrawEllipse(5,5,2,2) - #gc.DrawEllipse(100,200,500,500) - - def _on_size(self, event): - '''EVT_SIZE handler.''' - event.Skip() - self.Refresh() - - def _on_mouse_events(self, event): - '''EVT_MOUSE_EVENTS handler.''' - # todo: maybe right click should give context menu with - # 'Sensitivity...' - # todo: make check: if left up and has capture, release capture - - self.Refresh() - - (w, h) = self.GetClientSize() - (x, y) = event.GetPositionTuple() - - - if event.LeftDown(): - self.being_dragged = True - self.snap_map = SnapMap( - snap_point_ratios=self._get_snap_points_as_ratios(), - base_drag_radius=self.base_drag_radius, - snap_point_drag_well=self.snap_point_drag_well, - initial_y=y, - initial_ratio=self.current_ratio - ) - - self.SetCursor(wx_tools.cursors.collection.get_closed_grab()) - # SetCursor must be before CaptureMouse because of wxPython/GTK - # weirdness - self.CaptureMouse() - - return - - if event.LeftIsDown() and self.HasCapture(): - ratio = self.snap_map.y_to_ratio(y) - value = self._ratio_to_value(ratio) - self.value_setter(value) - - - if event.LeftUp(): - # todo: make sure that when leaving - # entire app, things don't get fucked - if self.HasCapture(): - self.ReleaseMouse() - # SetCursor must be after ReleaseMouse because of wxPython/GTK - # weirdness - self.SetCursor(wx_tools.cursors.collection.get_open_grab()) - self.being_dragged = False - self.snap_map = None - - - return - - - - - diff --git a/python_toolbox/wx_tools/widgets/knob/snap_map.py b/python_toolbox/wx_tools/widgets/knob/snap_map.py deleted file mode 100644 index 6a0be432f..000000000 --- a/python_toolbox/wx_tools/widgets/knob/snap_map.py +++ /dev/null @@ -1,208 +0,0 @@ -# Copyright 2009-2011 Ram Rachum. -# This program is distributed under the LGPL2.1 license. - -''' -Defines the `SnapMap` class. - -See its documentation for more info. -''' - -from __future__ import division -from python_toolbox import misc_tools - - -FUZZ = 0.001 -''' -The fuzziness of floating point numbers. - -If two floats have a distance of less than FUZZ, we may treat them as identical. -''' - - -class SnapMap: - ''' - Map for deciding which angle the knob will have when mouse-dragging. - - - Here we have three "scales" we are playing in: - - 1. The "ratio" scale. See documenation on Knob for that one. This controls - the angle of the knob and the actual value of the final variable. - - 2. The "y" scale. This is the `y` reading of the mouse on the screen. - - 3. The "pos" scale. This is a convenient mediator between the first two. It - is reversed from "y", because on the screen a higher number of y means - "down", and that's just wrong. Also, it has some translation. - - ''' - def __init__(self, snap_point_ratios, base_drag_radius, - snap_point_drag_well, initial_y, initial_ratio): - - assert snap_point_ratios == sorted(snap_point_ratios) - - self.snap_point_ratios = snap_point_ratios - '''Ordered list of snap points, as ratios.''' - - self.base_drag_radius = base_drag_radius - ''' - The base drag radius, in pixels. - - This number is the basis for calculating the height of the area in which - the user can play with the mouse to turn the knob. Beyond that area the - knob will be turned all the way to one side, and any movement farther - will have no effect. - - If there are no snap points, the total height of that area will be `2 * - self.base_drag_radius`. - ''' - - self.snap_point_drag_well = snap_point_drag_well - ''' - The height of a snap point's drag well, in pixels. - - This is the height of the area on the screen in which, when the user - drags to it, the knob will have the value of the snap point. - - The bigger this is, the harder the snap point "traps" the mouse. - ''' - - self.initial_y = initial_y - '''The y that was recorded when the user started dragging.''' - - self.initial_ratio = initial_ratio - '''The ratio that was recorded when the user started dragging.''' - - self.initial_pos = self.ratio_to_pos(initial_ratio) - '''The pos that was recorded when the user started dragging.''' - - self.max_pos = base_drag_radius * 2 + \ - len(snap_point_ratios) * snap_point_drag_well - '''The maximum that a pos number can reach before it gets truncated.''' - - self._make_snap_point_pos_starts() - - - ########################################################################### - # # # # Converters: - ############ - - def ratio_to_pos(self, ratio): - '''Convert from ratio to pos.''' - assert (- 1 - FUZZ) <= ratio <= 1 + FUZZ - n_snap_points_from_bottom = self._get_n_snap_points_from_bottom(ratio) - padding = n_snap_points_from_bottom * self.snap_point_drag_well - distance_from_bottom = ratio - (-1) - result = padding + distance_from_bottom * self.base_drag_radius - return result - - def pos_to_y(self, pos): - '''Convert from pos to y.''' - assert 0 - FUZZ <= pos <= self.max_pos + FUZZ - relative_pos = (pos - self.initial_pos) - return self.initial_y - relative_pos - # doing minus because y is upside down - - def y_to_pos(self, y): - '''Convert from y to pos.''' - relative_y = (y - self.initial_y) - - # doing minus because y is upside down - pos = self.initial_pos - relative_y - - if pos < 0: - pos = 0 - if pos > self.max_pos: - pos = self.max_pos - - return pos - - - def pos_to_ratio(self, pos): - '''Convert from pos to ratio.''' - assert 0 - FUZZ <= pos <= self.max_pos + FUZZ - - snap_point_pos_starts_from_bottom = [ - p for p in self.snap_point_pos_starts if p <= pos - ] - - padding = 0 - - if snap_point_pos_starts_from_bottom: - - candidate_for_current_snap_point = \ - snap_point_pos_starts_from_bottom[-1] - - distance_from_candidate = (pos - candidate_for_current_snap_point) - - if distance_from_candidate < self.snap_point_drag_well: - - # It IS the current snap point! - - snap_point_pos_starts_from_bottom.remove( - candidate_for_current_snap_point - ) - - padding += distance_from_candidate - - padding += \ - len(snap_point_pos_starts_from_bottom) * self.snap_point_drag_well - - - ratio = ((pos - padding) / self.base_drag_radius) - 1 - - assert (- 1 - FUZZ) <= ratio <= 1 + FUZZ - - return ratio - - - def ratio_to_y(self, ratio): - '''Convert from ratio to y.''' - return self.pos_to_y(self.ratio_to_pos(ratio)) - - def y_to_ratio(self, y): - '''Convert from y to ratio.''' - return self.pos_to_ratio(self.y_to_pos(y)) - - ########################################################################### - - def _get_n_snap_points_from_bottom(self, ratio): - '''Get the number of snap points whose ratio is lower than `ratio`.''' - raw_list = [s for s in self.snap_point_ratios - if -1 <= s <= (ratio + FUZZ)] - - if not raw_list: - return 0 - else: # len(raw_list) >= 1 - counter = 0 - counter += len(raw_list[:-1]) - last_snap_point = raw_list[-1] - ratio_in_last_snap_point = (abs(last_snap_point - ratio) < FUZZ) - if ratio_in_last_snap_point: - counter += 0.5 - else: - counter += 1 - return counter - - - def _make_snap_point_pos_starts(self): - ''' - Make a list with a "pos start" for each snap point. - - A "pos start" is the lowest point, in pos scale, of a snap point's drag - well. - - The list is not returned, but is stored as the attribute - `.snap_point_pos_starts`. - ''' - - self.snap_point_pos_starts = [] - - for i, ratio in enumerate(self.snap_point_ratios): - self.snap_point_pos_starts.append( - (1 + ratio) * self.base_drag_radius + \ - i * self.snap_point_drag_well - ) - - -