66"""
77
88import customtkinter
9+ import re
910from .text_menu import TextMenu
1011from .add_line_nums import AddLineNums
1112from pygments import lex , lexer
1213from pygments .styles import get_style_by_name , get_all_styles
13- from .constants import common_langs
14+ from .constants import *
1415from .custom_exception_classes import *
1516from .dataclasses import *
16- from .keybinding import _register_keybind
17+ from .keybinding import register_keybind
1718from typing import Union
1819import 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
0 commit comments