diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index f032b8c..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 67bf29d..282d087 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ *.so .DS_Store +**/.DS_Store # Distribution / packaging .Python diff --git a/docs/_static/content_scale.png b/docs/_static/content_scale.png new file mode 100644 index 0000000..43e145f Binary files /dev/null and b/docs/_static/content_scale.png differ diff --git a/docs/text_rendering.rst b/docs/text_rendering.rst index f44a367..37debc9 100644 --- a/docs/text_rendering.rst +++ b/docs/text_rendering.rst @@ -30,3 +30,18 @@ Recommended usage - Use ``GLTextSDF`` when scalable text quality matters and shaping is simple. - The old Pillow texture-backed constructor is backbenched as ``tachypy.text.LegacyText`` for compatibility. + +HiDPI and Retina displays +-------------------------- + +On Retina displays the framebuffer has more physical pixels than logical pixels. +Pass ``screen.content_scale`` so FreeType rasterizes at the correct physical +resolution — otherwise text appears blurry. + +.. code-block:: python + + label = Text("Hello", content_scale=screen.content_scale) + +.. image:: _static/content_scale.png + :alt: content_scale=1 (blurry) vs content_scale=2 (sharp) + :align: center diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 0cb32f0..0000000 Binary files a/src/.DS_Store and /dev/null differ diff --git a/src/tachypy/glsystemtext.py b/src/tachypy/glsystemtext.py index 7247aef..c274621 100644 --- a/src/tachypy/glsystemtext.py +++ b/src/tachypy/glsystemtext.py @@ -83,8 +83,40 @@ def __init__( align: str = "center", vertical_align: str = "center", fallback_renderer: str = "bitmap", + content_scale: float = 1.0, ): - """Create a system-font text renderer with OpenGL quad drawing.""" + """Create a system-font text renderer with OpenGL quad drawing. + + Parameters + ---------- + text : str + Text to render. Newlines (``\\n``) produce multi-line output. + dest_rect : tuple (x1, y1, x2, y2), optional + Bounding box in logical pixels (top-left origin). Text is laid + out and word-wrapped within this rectangle. If omitted, text + starts at the origin with no wrapping. + font_name : str + Family name (``"Helvetica"``), comma-separated fallback list + (``"Avenir, Helvetica, Arial"``), or an absolute font file path. + font_size : float + Size in logical points. + color : sequence of 3 ints + RGB color in the 0–255 range. + line_spacing : float + Line-height multiplier relative to the font's natural height. + align : str + Horizontal alignment within ``dest_rect``: ``"left"``, + ``"center"``, or ``"right"``. + vertical_align : str + Vertical alignment within ``dest_rect``: ``"top"``, + ``"center"``, or ``"bottom"``. + fallback_renderer : str + Renderer used when FreeType/HarfBuzz are unavailable: + ``"bitmap"`` (default) or ``"sdf"``. + content_scale : float + Pass ``screen.content_scale`` to get sharp text on Retina/HiDPI + displays (e.g. 2.0); omit for standard displays. + """ self.text = text self.dest_rect = dest_rect self.font_name = font_name @@ -103,6 +135,7 @@ def __init__( self._glyph_cache: Dict[int, _GlyphTexture] = {} self._line_height = float(font_size) self._ascender = float(font_size * 0.8) + self._content_scale = float(content_scale) if HAS_FREETYPE and HAS_HARFBUZZ: font_path = self.resolve_font_path(self.font_name) @@ -165,6 +198,13 @@ def _tokenize_font_query(font_name: str) -> List[str]: normalized = re.sub(r"[^a-z0-9]+", " ", str(font_name).lower()).strip() return [part for part in normalized.split() if part] + # Style qualifiers penalized in resolve_font_path() unless requested. + _STYLE_WORDS = { + "italic", "oblique", "bold", "narrow", "condensed", "black", "light", + "medium", "semibold", "semilight", "thin", "heavy", "extrabold", + "ultra", "expanded", "book", "regular", + } + @classmethod def resolve_font_path(cls, font_name: str) -> Optional[Path]: """Resolve a system font from a family/path query. @@ -173,6 +213,13 @@ def resolve_font_path(cls, font_name: str) -> Optional[Path]: - absolute/relative font file paths - comma-separated fallback font families (e.g. "Avenir, Helvetica, Arial") - partial family/style matching against system font file names + + Style words (e.g. "Bold", "Italic") in the query are matched against + the font file, but unrequested style words present in a candidate's + file name are penalized so a plain query like "Arial" prefers the + regular weight over "Arial Narrow Italic". An exact family match + (e.g. "Arial" -> "Arial.ttf") is also preferred over a candidate with + extra qualifiers (e.g. "Arial Unicode"). """ if not font_name: return None @@ -193,25 +240,31 @@ def resolve_font_path(cls, font_name: str) -> Optional[Path]: tokens = cls._tokenize_font_query(query) if not tokens: continue - best_score = -1 - best_path = None + requested_styles = set(tokens) & cls._STYLE_WORDS + + # Rank by relevance, then by style/exactness (see docstring). + scored = [] for path in candidates: stem_tokens = cls._tokenize_font_query(path.stem) stem_joined = " ".join(stem_tokens) - score = 0 + relevance = 0 for token in tokens: if token in stem_tokens: - score += 3 + relevance += 3 elif token in stem_joined: - score += 1 + relevance += 1 + + if relevance <= 0: + continue - if score > best_score: - best_score = score - best_path = path + unrequested_styles = (set(stem_tokens) & cls._STYLE_WORDS) - requested_styles + exact_match = set(stem_tokens) == set(tokens) + scored.append((relevance, -len(unrequested_styles), exact_match, path)) - if best_score > 0 and best_path is not None: - return best_path + if scored: + scored.sort(key=lambda item: (item[0], item[1], item[2]), reverse=True) + return scored[0][3] # Last-resort defaults. for fallback in ( @@ -231,16 +284,18 @@ def resolve_font_path(cls, font_name: str) -> Optional[Path]: def _init_system_font(self, font_path: Path) -> None: """Initialize FreeType face and HarfBuzz font from a font file.""" + physical_size = int(self.font_size * self._content_scale * 64) + self._font_bytes = font_path.read_bytes() self._face = freetype.Face(str(font_path)) - self._face.set_char_size(int(self.font_size * 64)) + self._face.set_char_size(physical_size) self._hb_font = hb.Font(hb.Face(self._font_bytes)) - self._hb_font.scale = (int(self.font_size * 64), int(self.font_size * 64)) + self._hb_font.scale = (physical_size, physical_size) metrics = self._face.size - self._line_height = max(1.0, float(metrics.height) / 64.0) * self.line_spacing - self._ascender = float(metrics.ascender) / 64.0 + self._line_height = max(1.0, float(metrics.height) / 64.0 / self._content_scale) * self.line_spacing + self._ascender = float(metrics.ascender) / 64.0 / self._content_scale def _shape(self, text: str): """Shape a string into glyph indices and positions via HarfBuzz.""" @@ -299,9 +354,38 @@ def _measure_line(self, line: str) -> float: pen_x = 0.0 for info, pos in zip(infos, positions): _ = info - pen_x += float(pos.x_advance) / 64.0 + pen_x += float(pos.x_advance) / 64.0 / self._content_scale return pen_x + def _line_bounds(self, line: str) -> Tuple[float, float, float]: + """Return the rendered line bounds as (width, top, bottom).""" + if line == "": + return 0.0, 0.0, 0.0 + + # Use actual glyph extents so descenders do not get vertically clipped. + infos, positions = self._shape(line) + scale = self._content_scale + pen_x = 0.0 + max_x = 0.0 + top = 0.0 + bottom = 0.0 + + for info, pos in zip(infos, positions): + glyph = self._glyph_texture(info.codepoint) + x_offset = float(pos.x_offset) / 64.0 / scale + y_offset = float(pos.y_offset) / 64.0 / scale + + glyph_x = pen_x + x_offset + glyph.bearing_x / scale + glyph_top = -glyph.bearing_y / scale - y_offset + glyph_bottom = glyph_top + glyph.height / scale + + max_x = max(max_x, glyph_x + glyph.width / scale) + top = min(top, glyph_top) + bottom = max(bottom, glyph_bottom) + pen_x += float(pos.x_advance) / 64.0 / scale + + return max(max_x, pen_x), top, bottom + def _split_lines(self) -> List[str]: """Wrap text lines to the destination rectangle width when provided.""" if not self.dest_rect: @@ -348,23 +432,26 @@ def draw(self): return lines = self._split_lines() - line_widths = [self._measure_line(line) for line in lines] - total_height = max(1.0, len(lines) * self._line_height) + line_bounds = [self._line_bounds(line) for line in lines] + line_widths = [bounds[0] for bounds in line_bounds] + block_top = min((i * self._line_height + top for i, (_, top, _) in enumerate(line_bounds)), default=0.0) + block_bottom = max((i * self._line_height + bottom for i, (_, _, bottom) in enumerate(line_bounds)), default=0.0) + total_height = max(1.0, block_bottom - block_top) if self.dest_rect: x1, y1, x2, y2 = self.dest_rect rect_w = float(x2 - x1) rect_h = float(y2 - y1) if self.vertical_align == "top": - baseline0 = y1 + self._ascender + baseline0 = y1 - block_top elif self.vertical_align == "bottom": - baseline0 = y1 + rect_h - total_height + self._ascender + baseline0 = y1 + rect_h - total_height - block_top else: - baseline0 = y1 + (rect_h - total_height) / 2.0 + self._ascender + baseline0 = y1 + (rect_h - total_height) / 2.0 - block_top else: x1 = 0.0 rect_w = max(line_widths) if line_widths else 0.0 - baseline0 = self._ascender + baseline0 = -block_top glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) @@ -373,6 +460,11 @@ def draw(self): baseline = baseline0 for line, line_w in zip(lines, line_widths): + if line == "": + # Handle empty lines (e.g., "\n\n") + baseline += self._line_height + continue + if self.dest_rect: if self.align == "left": pen_x = x1 @@ -384,16 +476,20 @@ def draw(self): pen_x = x1 infos, positions = self._shape(line) + scale = self._content_scale + # Align glyph quads to physical pixels to avoid interpolation blur. + snap = (lambda value: round(value * scale) / scale) if scale > 0 else (lambda value: value) for info, pos in zip(infos, positions): glyph = self._glyph_texture(info.codepoint) - x_offset = float(pos.x_offset) / 64.0 - y_offset = float(pos.y_offset) / 64.0 + x_offset = float(pos.x_offset) / 64.0 / scale + y_offset = float(pos.y_offset) / 64.0 / scale - x = pen_x + x_offset + glyph.bearing_x - y = baseline - glyph.bearing_y - y_offset - x2 = x + glyph.width - y2 = y + glyph.height + x = pen_x + x_offset + glyph.bearing_x / scale + y = baseline - glyph.bearing_y / scale - y_offset + x2 = x + glyph.width / scale + y2 = y + glyph.height / scale + x, y, x2, y2 = (snap(value) for value in (x, y, x2, y2)) glBindTexture(GL_TEXTURE_2D, glyph.texture_id) glBegin(GL_QUADS) @@ -408,7 +504,7 @@ def draw(self): glVertex2f(x, y2) glEnd() - pen_x += float(pos.x_advance) / 64.0 + pen_x += float(pos.x_advance) / 64.0 / scale baseline += self._line_height diff --git a/src/tachypy/quest.py b/src/tachypy/quest.py index 5cc70fd..e1589cd 100644 --- a/src/tachypy/quest.py +++ b/src/tachypy/quest.py @@ -70,27 +70,30 @@ def alt_round(x): class QuestObject: """Measure threshold using a Weibull psychometric function. - - Threshold 't' is measured on an abstract 'intensity' scale, which - usually corresponds to log10 contrast. + + Threshold 't' is measured on an abstract *intensity* scale — a + monotonic stimulus dimension (e.g. log10 contrast for visual + experiments, dB SPL for auditory experiments). All trial levels + passed to ``update()`` and returned by ``quantile()``/``mean()`` + must be expressed in the same units. The Weibull psychometric function: - + p2=delta*gamma+(1-delta)*(1-(1-gamma)*exp(-10**(beta*(x2+xThreshold)))) where x represents log10 contrast relative to threshold. The Weibull function itself appears only in recompute(), which uses the specified parameter values in self to compute a psychometric function and store it in self. All the other methods simply use - the psychometric function stored as instance - variables. recompute() is called solely by __init__() and - beta_analysis() (and possibly by a few user programs). Thus, if - you prefer to use a different kind of psychometric function, - called Foo, you need only subclass QuestObject, overriding - __init__(), recompute(), and (if you need it) beta_analysis(). + the psychometric function stored as instance variables. + recompute() is called solely by __init__() and beta_analysis() + (and possibly by a few user programs). Thus, if you prefer to use + a different kind of psychometric function, called Foo, you need + only subclass QuestObject, overriding __init__(), recompute(), and + (if you need it) beta_analysis(). instance variables: - + tGuess is your prior threshold estimate. tGuessSd is the standard deviation you assign to that guess. @@ -103,11 +106,12 @@ class QuestObject: beta, delta, and gamma are the parameters of a Weibull psychometric function. - beta controls the steepness of the psychometric - function. Typically 3.5. + beta controls the steepness of the psychometric function. + Typically 3.5. delta is the fraction of trials on which the observer presses - blindly. Typically 0.01. + blindly. + Typically 0.01. gamma is the fraction of trials that will generate response 1 when intensity==-inf. @@ -117,10 +121,16 @@ class QuestObject: range is the intensity difference between the largest and smallest intensity that the internal table can store. E.g. 5. This interval will be centered on the initial guess tGuess, - i.e. [tGuess-range/2, tGuess+range/2]. QUEST assumes that + i.e. [tGuess-range/2, tGuess+range/2]. QUEST assumes that intensities outside of this interval have zero prior probability, i.e. they are impossible. + pdf is a 1-D numpy array holding the posterior probability density + over possible threshold values. It is indexed by the intensity + axis self.x (offsets from tGuess, spaced by grain). Initialized + as a Gaussian prior and updated via Bayes' rule after each trial + by update() / recompute(). + """ def __init__(self,tGuess,tGuessSd,pThreshold,beta,delta,gamma,grain=0.01,range=None): """Initialize Quest parameters. @@ -219,13 +229,14 @@ def mode(self): return t,p def p(self,x): - """probability of correct response at intensity x. + """Probability of correct response at intensity x relative to threshold. p=q.p(x) - - The probability of a correct (or yes) response at intensity x, - assuming threshold is at x=0. - + + x is an intensity offset from threshold (i.e. x=0 means the + stimulus is presented at threshold). Returns the probability of + a correct (or yes) response according to the fitted Weibull. + This was converted from the Psychtoolbox's QuestP function. """ if x < self.x2[0]: @@ -380,14 +391,19 @@ def recompute(self): def update(self,intensity,response): """Update Quest posterior pdf. + intensity is the stimulus level shown on this trial, expressed + in the same intensity units as tGuess (e.g. log10 contrast). + response is 1 for a correct/yes response, 0 for incorrect/no. + Update self to reflect the results of this trial. The historical records self.intensity and self.response are always updated, but self.pdf is only updated if self.updatePdf is - true. You can always call QuestRecompute to recreate q.pdf + True. You can always call recompute() to recreate self.pdf from scratch from the historical record. This was converted from the Psychtoolbox's QuestUpdate function.""" - + # Normalize bool-like responses to integer indices for consistent NumPy behavior. + response = int(response) if response < 0 or response > self.s2.shape[0]: raise RuntimeError('response %g out of range 0 to %d'%(response,self.s2.shape[0])) if self.updatePdf: diff --git a/src/tachypy/responses.py b/src/tachypy/responses.py index d8b092c..7458570 100644 --- a/src/tachypy/responses.py +++ b/src/tachypy/responses.py @@ -388,18 +388,80 @@ def set_position(self, x, y): self._glfw.set_cursor_pos(self._window, float(x), float(y)) self.mouse_position = (float(x), float(y)) - def wait_for_keypress(self, keys=None, timeout=None, screen=None, time_reference_ns=None): - """Block until one of the listed keys is pressed, keeping the display alive.""" - active_screen = screen if screen is not None else self.screen + def wait_for_keypress(self, + keys=None, + timeout=None, + time_reference_ns=None, + callback=None, + callback_delay=None): + """Block until one of the listed keys is pressed, keeping the display alive. + + Parameters + ---------- + keys : sequence of str, optional + Keys to wait for (e.g. ``["z", "c"]``). If omitted, returns on any + tracked keydown event instead of a specific key. + timeout : float, optional + Maximum time to wait, in seconds. + time_reference_ns : int, optional + Timestamp in the ``time.monotonic_ns()`` domain -- e.g. the return + value of ``Screen.flip()`` -- to measure ``elapsed`` from, instead + of the moment this call starts. + callback : callable, optional + Zero-argument callable executed once, ``callback_delay`` seconds after + this call started (or after ``time_reference_ns``, if given) -- unless + a key is pressed before then, in which case it never fires. Does not + end the wait: after firing, polling continues normally until a key + is pressed, ``timeout`` elapses, or a quit is requested. + callback_delay : float, optional + Delay in seconds after which ``callback`` fires. + + Returns + ------- + tuple[str | None, float] + ``(key, elapsed)`` -- the normalized name of the key that was + pressed, or ``None`` on timeout or quit; and seconds elapsed + since the timing origin (``time_reference_ns``, or when this call + started if not given). + + Raises + ------ + ValueError + If ``callback`` is given without a positive ``callback_delay``. + RuntimeError + If ``callback`` raises an exception. + + Examples + -------- + Show a stimulus, then clear the screen to gray after a fixed 500 ms + viewing duration if no response has arrived yet. If the participant + responds within the first 500 ms, ``hide_stimulus`` never runs and + the stimulus simply stays on screen until the key is detected: + + >>> ... # draw the stimulus + >>> screen.flip() + >>> def hide_stimulus(): + ... screen.fill([128, 128, 128]) # back to a plain gray screen + ... screen.flip() + >>> key, rt = response_handler.wait_for_keypress( + ... keys=["z", "c"], callback=hide_stimulus, callback_delay=0.5, + ... ) + """ + if callback is not None and (callback_delay is None or callback_delay <= 0): + raise ValueError("callback_delay must be > 0 when callback is provided.") + self.clear_events() if keys is not None: for key in keys: self._probed_keys.add(self._normalize_key_name(key)) ref_ns = None + callback_done = False + first_iteration = True while True: - if active_screen is not None: - active_screen.flip() + if not first_iteration: + time.sleep(0.001) + first_iteration = False if ref_ns is None: ref_ns = time_reference_ns if time_reference_ns is not None else time.monotonic_ns() @@ -407,10 +469,15 @@ def wait_for_keypress(self, keys=None, timeout=None, screen=None, time_reference self.get_events() elapsed = (time.monotonic_ns() - ref_ns) / 1e9 - if self.should_quit(): - return None, elapsed - if timeout is not None and elapsed >= timeout: - return None, elapsed + if callback is not None and not callback_done and elapsed >= callback_delay: + try: + callback() + except Exception as e: + raise RuntimeError("Timed callback failed.") from e + callback_done = True + + # Honor keypresses detected in this polling cycle before applying quit + # or timeout checks, preventing boundary events from being dropped. if keys is None: if self.key_down_events: return next(iter(self.key_down_events)), elapsed @@ -419,6 +486,11 @@ def wait_for_keypress(self, keys=None, timeout=None, screen=None, time_reference if self.was_key_pressed(key): return self._normalize_key_name(key), elapsed + if self.should_quit(): + return None, elapsed + if timeout is not None and elapsed >= timeout: + return None, elapsed + def clear_events(self): """Clear tracked transition events while preserving held-state snapshots.""" self.key_presses.clear() diff --git a/src/tachypy/screen.py b/src/tachypy/screen.py index 22fca74..3649e05 100644 --- a/src/tachypy/screen.py +++ b/src/tachypy/screen.py @@ -71,6 +71,7 @@ def __init__( self.screen = None self._glfw = None self._glfw_window = None + self.content_scale: float = 1.0 self._init_glfw_backend(screen_number) self._init_opengl_state() @@ -326,6 +327,7 @@ def _sync_glfw_viewport_and_projection(self, force: bool = False) -> None: # Always keep viewport in framebuffer pixels (HiDPI-safe). glViewport(0, 0, int(fb_w), int(fb_h)) + self.content_scale = float(fb_h) / float(win_h) if win_h > 0 else 1.0 if logical_changed: # Use TachyPy's top-left logical origin convention. diff --git a/src/tachypy/wooting/__init__.py b/src/tachypy/wooting/__init__.py index acc7f3a..228b981 100644 --- a/src/tachypy/wooting/__init__.py +++ b/src/tachypy/wooting/__init__.py @@ -24,6 +24,7 @@ # Re-export the keyboard's public API so experiments need only one import. from tachywooting import ( # noqa: F401 convert_char_to_keycode, + convert_keycode_to_char, ffi, lib, load_session, @@ -45,6 +46,7 @@ class WOOTING_ACQUISITION(_tachywooting.WOOTING_ACQUISITION, VisualPressureFeedb __all__ = [ "WOOTING_ACQUISITION", "convert_char_to_keycode", + "convert_keycode_to_char", "ffi", "lib", "load_session", diff --git a/src/tachypy/wooting/demos/mini_bw_experiment.py b/src/tachypy/wooting/demos/mini_bw_experiment.py index 78eb46f..3dd9194 100644 --- a/src/tachypy/wooting/demos/mini_bw_experiment.py +++ b/src/tachypy/wooting/demos/mini_bw_experiment.py @@ -100,7 +100,7 @@ def main() -> int: screen.flip() hier = acq.acquire_analog_values(target_keys=[YES_KEY, NO_KEY]) - response = acq.get_response_key(hier, target_keys=[YES_KEY, NO_KEY]) + response, _rt = acq.get_response_key(hier) is_correct = response == (yes_code if is_white else no_code) had_removal = acq.last_trial_had_removal diff --git a/tests/test_audio.py b/tests/test_audio.py index 6dfb93b..8843cc1 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,8 +1,15 @@ import numpy as np import pytest +import tachypy.audio as audio_module from tachypy.audio import Audio +_FAKE_TACHYAUDIO = type("FakeTachyAudio", (), {})() + + +def _patch_tachyaudio(monkeypatch): + monkeypatch.setattr(audio_module, "tachyaudio", _FAKE_TACHYAUDIO) + def test_sleep_duration_for_remaining_ns_thresholds(): assert Audio._sleep_duration_for_remaining_ns(-1) is None @@ -12,6 +19,7 @@ def test_sleep_duration_for_remaining_ns_thresholds(): def test_play_expands_mono_data_to_requested_channels(monkeypatch): + _patch_tachyaudio(monkeypatch) captured = {} def fake_playback(self, data, delay): @@ -34,14 +42,15 @@ def start(self): audio = Audio(sample_rate=44_100, channels=2) mono = np.array([0.1, -0.1, 0.2], dtype=np.float64) - audio.play(mono, when=1.0) + audio.play(mono, when=2.0) assert captured["shape"] == (3, 2) assert captured["dtype"] == np.float32 assert captured["delay"] == 0 -def test_play_raises_for_wrong_channel_count(): +def test_play_raises_for_wrong_channel_count(monkeypatch): + _patch_tachyaudio(monkeypatch) audio = Audio(channels=2) stereo = np.array([[0.1], [0.2]], dtype=np.float32) @@ -55,6 +64,7 @@ def test_invalid_backend_raises(): def test_backend_env_accepts_auto(monkeypatch): + _patch_tachyaudio(monkeypatch) monkeypatch.setenv("TACHYPY_AUDIO_BACKEND", "auto") audio = Audio() assert audio.backend_name == "tachyaudio" diff --git a/tests/test_audio_extended.py b/tests/test_audio_extended.py index 5cf2c4f..cca0b41 100644 --- a/tests/test_audio_extended.py +++ b/tests/test_audio_extended.py @@ -40,7 +40,8 @@ def install_fake_tachyaudio(monkeypatch): return fake -def test_audio_play_validation_errors(): +def test_audio_play_validation_errors(monkeypatch): + install_fake_tachyaudio(monkeypatch) audio = Audio() with pytest.raises(ValueError, match="NumPy"): audio.play([1, 2, 3], when=0) diff --git a/tests/test_glsystemtext.py b/tests/test_glsystemtext.py index b150f24..67f8a49 100644 --- a/tests/test_glsystemtext.py +++ b/tests/test_glsystemtext.py @@ -1,7 +1,10 @@ +import pytest + import tachypy.glsystemtext as glsys_module import tachypy.text as text_module from tachypy.glsystemtext import GLSystemText from pathlib import Path +from types import SimpleNamespace class FakeFallback: @@ -70,3 +73,92 @@ def test_resolve_font_path_uses_best_token_match(monkeypatch): resolved = GLSystemText.resolve_font_path("Helvetica Neue, Arial") assert resolved == Path("/tmp/HelveticaNeue-Bold.ttf") + + +def test_resolve_font_path_prefers_regular_over_unrequested_style_variants(monkeypatch): + # Regression test: a plain family query must not resolve to a styled + # variant just because it happens to be scored/iterated first. + fake_fonts = [ + Path("/tmp/Arial Narrow Italic.ttf"), + Path("/tmp/Arial Bold Italic.ttf"), + Path("/tmp/Arial Bold.ttf"), + Path("/tmp/Arial Italic.ttf"), + Path("/tmp/Arial.ttf"), + ] + monkeypatch.setattr(GLSystemText, "_iter_system_font_files", staticmethod(lambda: fake_fonts)) + + assert GLSystemText.resolve_font_path("Arial") == Path("/tmp/Arial.ttf") + assert GLSystemText.resolve_font_path("Arial Italic") == Path("/tmp/Arial Italic.ttf") + assert GLSystemText.resolve_font_path("Arial Bold") == Path("/tmp/Arial Bold.ttf") + + +def test_draw_skips_blank_lines_without_crashing(monkeypatch): + # Regression test: a blank line (e.g. from "\n\n" in the source text, or + # from word-wrapping) makes HarfBuzz return glyph_positions=None for the + # empty buffer (no script could be guessed), which used to crash + # zip(infos, positions) in draw(). + pytest.importorskip("freetype") + pytest.importorskip("uharfbuzz") + + text = GLSystemText( + "Line one.\n\nLine two.", + dest_rect=[0, 0, 500, 300], + font_name="Arial", + font_size=32.0, + ) + assert text._enabled, "expected the real freetype+harfbuzz path to be active" + assert "" in text._split_lines() + + fake_glyph = glsys_module._GlyphTexture(texture_id=0, width=1, height=1, bearing_x=0.0, bearing_y=0.0) + monkeypatch.setattr(text, "_glyph_texture", lambda codepoint: fake_glyph) + noop = lambda *args, **kwargs: None + for gl_func in ("glEnable", "glDisable", "glBlendFunc", "glColor3f", "glBindTexture", "glBegin", "glEnd", "glTexCoord2f", "glVertex2f"): + monkeypatch.setattr(glsys_module, gl_func, noop) + + text.draw() # must not raise + + +def test_draw_centers_actual_glyph_bounds_in_dest_rect(monkeypatch): + # Regression test for descenders/bottom-heavy glyphs: vertical alignment + # must use the rendered glyph bounds, not only ascender/line-height metrics. + text = GLSystemText( + "g", + dest_rect=[0, 0, 100, 10], + font_size=10.0, + ) + text._enabled = True + text._fallback = None + text._content_scale = 1.0 + text._line_height = 10.0 + text._ascender = 8.0 + + fake_info = SimpleNamespace(codepoint=1) + fake_pos = SimpleNamespace(x_advance=9 * 64, x_offset=0, y_offset=0) + fake_glyph = glsys_module._GlyphTexture( + texture_id=0, + width=9, + height=10, + bearing_x=0.0, + bearing_y=5.0, + ) + monkeypatch.setattr(text, "_shape", lambda line: ([fake_info], [fake_pos])) + monkeypatch.setattr(text, "_glyph_texture", lambda codepoint: fake_glyph) + + vertices = [] + + def capture_vertex(x, y): + vertices.append((x, y)) + + noop = lambda *args, **kwargs: None + for gl_func in ("glEnable", "glDisable", "glBlendFunc", "glColor3f", "glBindTexture", "glBegin", "glEnd", "glTexCoord2f"): + monkeypatch.setattr(glsys_module, gl_func, noop) + monkeypatch.setattr(glsys_module, "glVertex2f", capture_vertex) + + text.draw() + + x_values = [x for x, _ in vertices] + y_values = [y for _, y in vertices] + assert min(x_values) == pytest.approx(46.0) + assert max(x_values) == pytest.approx(54.0) + assert min(y_values) == pytest.approx(0.0) + assert max(y_values) == pytest.approx(10.0) diff --git a/tests/test_pressure_keyboard.py b/tests/test_pressure_keyboard.py index be9032f..0ff806d 100644 --- a/tests/test_pressure_keyboard.py +++ b/tests/test_pressure_keyboard.py @@ -32,6 +32,7 @@ class BaseAcquisition: fake_tachywooting = types.ModuleType("tachywooting") fake_tachywooting.WOOTING_ACQUISITION = BaseAcquisition fake_tachywooting.convert_char_to_keycode = lambda keys: keys + fake_tachywooting.convert_keycode_to_char = lambda keycode: keycode fake_tachywooting.ffi = object() fake_tachywooting.lib = object() fake_tachywooting.load_session = lambda *args, **kwargs: None diff --git a/tests/test_responses_extended.py b/tests/test_responses_extended.py index fc468d0..f05f28b 100644 --- a/tests/test_responses_extended.py +++ b/tests/test_responses_extended.py @@ -21,17 +21,22 @@ def test_wait_for_keypress_returns_key_and_rt(): screen = FakeScreen() handler = ResponseHandler(keys_to_listen=["left", "right"], screen=screen) - def flip(): - screen.flip_count += 1 - if screen.flip_count >= 3: + poll_count = {"n": 0} + original_poll_events = screen.poll_events + + def poll_events(): + poll_count["n"] += 1 + if poll_count["n"] >= 3: screen._glfw.down_keys = {FakeGlfw.KEY_LEFT} + original_poll_events() - screen.flip = flip + screen.poll_events = poll_events key, rt = handler.wait_for_keypress(keys=["left", "right"]) assert key == "left" assert rt >= 0 - assert screen.flip_count == 3 + assert poll_count["n"] == 3 + assert screen.flip_count == 0 def test_wait_for_keypress_timeout(): @@ -44,6 +49,96 @@ def test_wait_for_keypress_timeout(): assert rt >= 0 +def test_wait_for_keypress_detected_key_wins_over_same_iteration_timeout(): + # Regression test: elapsed can already reach `timeout` in the very iteration + # a key is detected (e.g. get_events()/poll_events() itself took longer than + # a very short timeout -- common with short polling intervals like 10ms). + # The keypress must still be honored, not silently dropped because the + # timeout check used to run before the key check. + screen = FakeScreen() + handler = ResponseHandler(screen=screen) + screen._glfw.down_keys = {FakeGlfw.KEY_SPACE} # already pressed on the very first poll + + key, rt = handler.wait_for_keypress(keys=["space"], timeout=0.0) + + assert key == "space" + + +def test_wait_for_keypress_default_still_exits_on_escape(): + screen = FakeScreen() + handler = ResponseHandler(keys_to_listen=["space"], screen=screen) + screen._glfw.down_keys = {FakeGlfw.KEY_ESCAPE} + + key, rt = handler.wait_for_keypress(keys=["space"]) + + assert key is None + assert rt >= 0 + + +def test_wait_for_keypress_callback_fires_once_on_timeout(): + screen = FakeScreen() + handler = ResponseHandler(screen=screen) + + calls = [] + key, rt = handler.wait_for_keypress( + keys=["space"], timeout=0.05, callback=lambda: calls.append(1), callback_delay=0.02, + ) + + assert key is None + assert calls == [1] + assert rt >= 0.02 + + +def test_wait_for_keypress_callback_canceled_by_early_keypress(): + screen = FakeScreen() + handler = ResponseHandler(keys_to_listen=["space"], screen=screen) + + poll_count = {"n": 0} + original_poll_events = screen.poll_events + + def poll_events(): + poll_count["n"] += 1 + if poll_count["n"] >= 3: # fires within a few ms, well before callback_delay + screen._glfw.down_keys = {FakeGlfw.KEY_SPACE} + original_poll_events() + + screen.poll_events = poll_events + + calls = [] + key, rt = handler.wait_for_keypress( + keys=["space"], callback=lambda: calls.append(1), callback_delay=1.0, + ) + + assert key == "space" + assert calls == [], "callback must not fire when the key arrives first" + + +def test_wait_for_keypress_requires_positive_callback_delay(): + screen = FakeScreen() + handler = ResponseHandler(screen=screen) + + for bad_delay in (None, 0, -1): + try: + handler.wait_for_keypress(keys=["space"], callback=lambda: None, callback_delay=bad_delay) + assert False, f"expected ValueError for callback_delay={bad_delay!r}" + except ValueError: + pass + + +def test_wait_for_keypress_callback_exception_becomes_runtime_error(): + screen = FakeScreen() + handler = ResponseHandler(screen=screen) + + def bad_callback(): + raise KeyError("boom") + + try: + handler.wait_for_keypress(keys=["space"], timeout=1.0, callback=bad_callback, callback_delay=0.01) + assert False, "expected RuntimeError" + except RuntimeError as e: + assert isinstance(e.__cause__, KeyError) + + def test_clear_events_preserves_held_state_snapshots(): screen = FakeScreen() handler = ResponseHandler(screen=screen)