Skip to content

Commit d564d40

Browse files
committed
Version 1.3:
fix: Fix of history push on backspace even when cursor is at the very beginning of line feat: Add search; replace; clear_search_highlight methods feat: Upgrade keybinding.py docs: Update README.md
1 parent 07e6fbe commit d564d40

File tree

8 files changed

+324
-75
lines changed

8 files changed

+324
-75
lines changed

CTkCodeBoxPlus/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
Homepage: https://github.com/KiTant/CTkCodeBoxPlus
77
"""
88

9-
__version__ = '1.2'
9+
__version__ = '1.3'
1010

1111
from .ctk_code_box import CTkCodeBox
1212
from .dataclasses import *
1313
from .custom_exception_classes import *
1414
from .text_menu import TextMenu
1515
from .add_line_nums import AddLineNums
16-
from .keybinding import _unregister_keybind, _register_keybind
16+
from .keybinding import unregister_keybind, register_keybind
17+
18+
__all__ = ["CTkCodeBox", "HistorySettings", "MenuSettings", "NumberingSettings", "TextMenu",
19+
"AddLineNums", "unregister_keybind", "register_keybind", "CTkCodeBoxError",
20+
"LanguageNotAvailableError", "ThemeNotAvailableError", "LexerError", "ConfigureBadType"]

CTkCodeBoxPlus/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@
4040
"swift": objective.SwiftLexer,
4141
"react": jsx.JsxLexer,
4242
}
43+
44+
_SEARCH_TAG = "_ctkcodebox_search"
45+
46+
__all__ = ["common_langs", "_SEARCH_TAG"]

CTkCodeBoxPlus/ctk_code_box.py

Lines changed: 168 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
"""
77

88
import customtkinter
9+
import re
910
from .text_menu import TextMenu
1011
from .add_line_nums import AddLineNums
1112
from pygments import lex, lexer
1213
from pygments.styles import get_style_by_name, get_all_styles
13-
from .constants import common_langs
14+
from .constants import *
1415
from .custom_exception_classes import *
1516
from .dataclasses import *
16-
from .keybinding import _register_keybind
17+
from .keybinding import register_keybind
1718
from typing import Union
1819
import pyperclip
1920

@@ -48,7 +49,6 @@ def __init__(self,
4849
indent_width: int = 4,
4950
**kwargs):
5051
"""Initialize a CTkCodeBox instance.
51-
5252
Args:
5353
master: Parent widget.
5454
language: Pygments language name (str) or a lexer class.
@@ -67,10 +67,7 @@ def __init__(self,
6767
"""
6868
# Do not enable Tk's built-in undo/history
6969
if 'undo' in kwargs and history_settings.enabled:
70-
try:
71-
del kwargs['undo']
72-
except Exception:
73-
kwargs.pop('undo', None)
70+
kwargs.pop('undo', None)
7471
super().__init__(master, height=height, **kwargs)
7572

7673
# Wrap management
@@ -95,12 +92,12 @@ def __init__(self,
9592
self.bind('<KeyRelease>', self.update_code) # When a key is released, update the code
9693
self.bind('<<ContentChanged>>', self.update_code)
9794
# Use keybindings for common editing actions (widget scope)
98-
_register_keybind(self, "CmdOrCtrl+A", lambda: self.select_all_text(), bind_scope='widget')
99-
_register_keybind(self, "CmdOrCtrl+X", lambda: self.cut_text(), bind_scope='widget')
100-
_register_keybind(self, "CmdOrCtrl+C", lambda: self.copy_text(), bind_scope='widget')
101-
_register_keybind(self, "CmdOrCtrl+V", lambda: self.paste_text(), bind_scope='widget')
102-
_register_keybind(self, "CmdOrCtrl+Shift+Z", lambda: self.redo(), bind_scope='widget')
103-
_register_keybind(self, "CmdOrCtrl+Z", lambda: self.undo(), bind_scope='widget')
95+
register_keybind(self, "CmdOrCtrl+A", lambda: self.select_all_text(), bind_scope='widget')
96+
register_keybind(self, "CmdOrCtrl+X", lambda: self.cut_text(), bind_scope='widget')
97+
register_keybind(self, "CmdOrCtrl+C", lambda: self.copy_text(), bind_scope='widget')
98+
register_keybind(self, "CmdOrCtrl+V", lambda: self.paste_text(), bind_scope='widget')
99+
register_keybind(self, "CmdOrCtrl+Shift+Z", lambda: self.redo(), bind_scope='widget')
100+
register_keybind(self, "CmdOrCtrl+Z", lambda: self.undo(), bind_scope='widget')
104101

105102
# Custom history (undo/redo)
106103
self._undo_stack = []
@@ -132,10 +129,10 @@ def __init__(self,
132129
# Indentation settings and keybindings
133130
self.indent_width = int(indent_width)
134131
# Register layout/platform-independent editing keys
135-
_register_keybind(self, 'TAB', lambda: self._on_tab(), bind_scope='widget')
132+
register_keybind(self, 'TAB', lambda: self._on_tab(), bind_scope='widget')
136133
# Use Shift-Tab for outdent;
137-
_register_keybind(self, 'Shift+TAB', lambda: self._on_shift_tab(), bind_scope='widget')
138-
_register_keybind(self, 'RETURN', lambda: self._on_return(), bind_scope='widget')
134+
register_keybind(self, 'Shift+TAB', lambda: self._on_shift_tab(), bind_scope='widget')
135+
register_keybind(self, 'RETURN', lambda: self._on_return(), bind_scope='widget')
139136

140137
self.theme_name = theme
141138
self.all_themes = list(get_all_styles())
@@ -224,7 +221,7 @@ def clear_code(self):
224221
"""Remove all highlighting tags while preserving selection."""
225222
for tag in self.tag_names():
226223
# Preserve current selection while re-highlighting
227-
if tag == "sel":
224+
if tag == "sel" or tag == _SEARCH_TAG:
228225
continue
229226
self.tag_remove(tag, '0.0', 'end')
230227

@@ -318,9 +315,6 @@ def configure(self, param=None, **kwargs): # param here ONLY for tklinenums mod
318315
if "select_color" in kwargs and self._configure_type_check(kwargs["select_color"], str, "select_color"):
319316
self.select_color = kwargs.pop("select_color")
320317
self._textbox.config(selectbackground=self.select_color)
321-
else:
322-
# Keep default selection color updated if user hasn't provided one
323-
self._apply_selection_colors()
324318
if "cursor_color" in kwargs and self._configure_type_check(kwargs["cursor_color"], str, "cursor_color"):
325319
self.cursor_color = kwargs.pop("cursor_color")
326320
self._textbox.config(insertbackground=self.cursor_color)
@@ -333,8 +327,12 @@ def configure(self, param=None, **kwargs): # param here ONLY for tklinenums mod
333327

334328
# Re-apply highlighting if theme or language changed
335329
self._schedule_highlight(0)
330+
# Update selection colors if fg_color is being changed (theme change)
331+
needs_selection_update = "fg_color" in kwargs and not self.select_color
336332
if kwargs:
337333
super().configure(**kwargs)
334+
if needs_selection_update:
335+
self._apply_selection_colors()
338336

339337
def cget(self, param):
340338
"""Get configuration parameter value with support for custom parameters.
@@ -370,6 +368,124 @@ def cget(self, param):
370368
return custom_params[param]()
371369
return super().cget(param)
372370

371+
def clear_search_highlight(self):
372+
"""Remove all search highlight tags."""
373+
try:
374+
self.tag_remove(_SEARCH_TAG, "1.0", "end")
375+
except Exception:
376+
pass
377+
378+
def _configure_search_tag(self):
379+
"""Configure visual style for search highlights (idempotent)."""
380+
try:
381+
# Light yellow highlight; keep text color unchanged
382+
self.tag_config(_SEARCH_TAG, background="#f7ea8f")
383+
except Exception:
384+
pass
385+
386+
def _build_search_regex(self, pattern: str, match_case: bool, words: bool, regex: bool):
387+
"""Return compiled regex respecting flags."""
388+
flags = 0 if match_case else re.IGNORECASE
389+
if not regex:
390+
pattern = re.escape(pattern)
391+
if words:
392+
pattern = r"\b" + pattern + r"\b"
393+
try:
394+
return re.compile(pattern, flags)
395+
except re.error as e:
396+
raise ValueError(f"Invalid regex pattern: {e}") from e
397+
398+
def search(self, pattern: str, match_case: bool = False, words: bool = False,
399+
regex: bool = False, start: str = "1.0", end: str = "end-1c"):
400+
"""Find all matches and highlight them.
401+
Args:
402+
pattern: Text or regex to find.
403+
match_case: If False, search ignores case.
404+
words: If True, match whole words only.
405+
regex: If True, treat pattern as regex (otherwise literal).
406+
start: Start index for search (Tk text index).
407+
end: End index for search (Tk text index, non-inclusive).
408+
Returns:
409+
List of (start_index, end_index) tuples for matches.
410+
"""
411+
self.clear_search_highlight()
412+
if not pattern:
413+
return []
414+
text = self.get(start, end)
415+
try:
416+
compiled = self._build_search_regex(pattern, match_case, words, regex)
417+
except ValueError:
418+
return []
419+
matches = []
420+
base_index = self.index(start)
421+
for match in compiled.finditer(text):
422+
if match.start() == match.end():
423+
continue
424+
idx_start = self.index(f"{base_index}+{match.start()}c")
425+
idx_end = self.index(f"{base_index}+{match.end()}c")
426+
matches.append((idx_start, idx_end))
427+
if matches:
428+
self._configure_search_tag()
429+
for s, e in matches:
430+
try:
431+
self.tag_add(_SEARCH_TAG, s, e)
432+
except Exception:
433+
pass
434+
try:
435+
self.tag_lower(_SEARCH_TAG, "sel")
436+
except Exception:
437+
pass
438+
# Do not re-highlight to preserve search tag; caller may request manually
439+
return matches
440+
return matches
441+
442+
def replace(self, symbols_to_find: str, symbols_to_replace: str, replace_all: bool = True,
443+
index_range: tuple = None, match_case: bool = False, words: bool = False, regex: bool = False):
444+
"""Replace occurrences of text.
445+
Args:
446+
symbols_to_find: Text or regex to search for.
447+
symbols_to_replace: Replacement text.
448+
replace_all: If True, replace in the whole buffer; otherwise only in index_range/selection.
449+
index_range: (start, end) Tk indices used when replace_all is False and no selection exists.
450+
match_case: Respect case when True.
451+
words: Match whole words only.
452+
regex: Treat symbols_to_find as regex when True.
453+
Returns:
454+
ReplaceResult with count of replacements and optional error message.
455+
"""
456+
if not symbols_to_find:
457+
return ReplaceResult(count=0)
458+
try:
459+
compiled = self._build_search_regex(symbols_to_find, match_case, words, regex)
460+
except ValueError as e:
461+
return ReplaceResult(count=0, error=str(e))
462+
# Determine target range
463+
start_idx, end_idx = "1.0", "end-1c"
464+
if not replace_all:
465+
if self.tag_ranges("sel"):
466+
start_idx, end_idx = self.index("sel.first"), self.index("sel.last")
467+
elif index_range and len(index_range) == 2:
468+
start_idx, end_idx = index_range
469+
else:
470+
return ReplaceResult(count=0, error="index_range required for partial replace")
471+
try:
472+
segment = self.get(start_idx, end_idx)
473+
except Exception as err:
474+
return ReplaceResult(count=0, error=f"failed to get text range: {err}")
475+
new_text, count = compiled.subn(symbols_to_replace, segment)
476+
if count == 0:
477+
return ReplaceResult(count=0)
478+
try:
479+
self._history_push_current()
480+
self.delete(start_idx, end_idx)
481+
self.insert(start_idx, new_text, push_history=False)
482+
self.clear_search_highlight()
483+
self._notify_content_changed()
484+
self._schedule_highlight(0)
485+
except Exception as err:
486+
return ReplaceResult(count=0, error=f"replace failed: {err}")
487+
return ReplaceResult(count=count)
488+
373489
# General helpers
374490
def set_wrap(self, enabled: bool):
375491
"""Enable/disable word wrap."""
@@ -598,8 +714,8 @@ def cut_text(self):
598714
self.delete("sel.first", "sel.last")
599715
self._notify_content_changed()
600716
return "Success"
601-
except Exception:
602-
return Exception
717+
except Exception as err:
718+
return err
603719

604720
def copy_text(self):
605721
"""Copy selected text to clipboard.
@@ -614,8 +730,8 @@ def copy_text(self):
614730
text = self.get("sel.first", "sel.last")
615731
pyperclip.copy(text)
616732
return "Success"
617-
except Exception:
618-
return Exception
733+
except Exception as err:
734+
return err
619735

620736
def paste_text(self):
621737
"""Paste clipboard text, replacing selection if present, and refresh highlighting/lines.
@@ -641,8 +757,8 @@ def paste_text(self):
641757
# Ensure quick re-highlight for multi-line pastes
642758
self._schedule_highlight(0)
643759
return "Success"
644-
except Exception:
645-
return Exception
760+
except Exception as err:
761+
return err
646762

647763
def clear_all_text(self):
648764
"""Delete all content and notify change.
@@ -655,8 +771,8 @@ def clear_all_text(self):
655771
self.delete("1.0", "end")
656772
self._notify_content_changed()
657773
return "Success"
658-
except Exception:
659-
return Exception
774+
except Exception as err:
775+
return err
660776

661777
def select_all_text(self):
662778
"""Select all content.
@@ -667,8 +783,8 @@ def select_all_text(self):
667783
try:
668784
self.tag_add("sel", "1.0", "end-1c")
669785
return "Success"
670-
except Exception:
671-
return Exception
786+
except Exception as err:
787+
return err
672788

673789
# History API
674790
def set_history_enabled(self, enabled: bool):
@@ -687,8 +803,8 @@ def set_history_limit(self, limit: int):
687803
if self.history_settings.max and len(self._undo_stack) > self.history_settings.max:
688804
self._undo_stack = self._undo_stack[-self.history_settings.max:]
689805
return "Success"
690-
except Exception:
691-
return Exception
806+
except Exception as err:
807+
return err
692808

693809
def clear_history(self):
694810
"""Clear undo and redo stacks."""
@@ -711,8 +827,8 @@ def undo(self):
711827
self._restore_state(state)
712828
self._notify_content_changed()
713829
return "Success"
714-
except Exception:
715-
return Exception
830+
except Exception as err:
831+
return err
716832

717833
def redo(self):
718834
"""Redo the last undone change if available.
@@ -730,8 +846,8 @@ def redo(self):
730846
self._restore_state(state)
731847
self._notify_content_changed()
732848
return "Success"
733-
except Exception:
734-
return Exception
849+
except Exception as err:
850+
return err
735851

736852
# Internal history helpers
737853
def _save_state(self):
@@ -907,20 +1023,32 @@ def _on_backspace(self, event=None):
9071023
Returns:
9081024
"break" to stop default backspace behavior.
9091025
"""
910-
self._history_push_current()
911-
self._notify_content_changed()
9121026
try:
913-
# If there is a selection, let default backspace handle it (history is handled elsewhere)
1027+
# If there is a selection, let default backspace handle it
9141028
if self.tag_ranges('sel'):
1029+
self._history_push_current()
1030+
self._notify_content_changed()
1031+
return None
1032+
# Check if cursor is at the very beginning (nothing to delete)
1033+
if not self.compare("insert", '>', '1.0'):
9151034
return None
1035+
9161036
# Check if we're between a pair: (), [], {}, <>, '' , "", ``
917-
prev_ch = self.get("insert -1c", "insert") if self.compare("insert", '>', '1.0') else ''
1037+
prev_ch = self.get("insert -1c", "insert")
9181038
next_ch = self.get("insert", "insert +1c")
9191039
pairs = {('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')}
1040+
9201041
if prev_ch and next_ch:
9211042
if (prev_ch, next_ch) in pairs or (prev_ch == next_ch and prev_ch in ('"', "'", '`')):
1043+
# Paired deletion: push history and delete both chars
1044+
self._history_push_current()
9221045
self.delete("insert -1c", "insert +1c")
1046+
self._notify_content_changed()
9231047
return "break"
1048+
# Regular backspace: let default handler run
1049+
self._history_push_current()
1050+
self._notify_content_changed()
1051+
return None
9241052
except Exception:
9251053
pass
9261054
return None

CTkCodeBoxPlus/dataclasses.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,12 @@ class NumberingSettings:
2525
padx: int = 30
2626
auto_padx: bool = True
2727

28-
__all__ = ["MenuSettings", "HistorySettings", "NumberingSettings"]
28+
29+
@dataclass(frozen=True)
30+
class ReplaceResult:
31+
"""Result of a replace operation."""
32+
count: int
33+
error: Optional[str] = None
34+
35+
36+
__all__ = ["MenuSettings", "HistorySettings", "NumberingSettings", "ReplaceResult"]

0 commit comments

Comments
 (0)