3434except ImportError :
3535 PIL_AVAILABLE = False
3636
37-
38- def _dummy_process_target ():
39- """A picklable top-level function to satisfy multiprocessing on Windows."""
40- pass
41-
42-
43- def launch_new_instance ():
44- """
45- Launches a new instance of the plotter application in a separate process.
46- This is necessary for creating independent windows.
47- """
48- # This function is no longer needed, as the logic is handled directly
49- # in the button command for clarity and robustness.
50- pass
51-
52-
5337def run_script_process (script_path ):
5438 """
5539 Wrapper function to execute a script in a new process.
@@ -314,18 +298,6 @@ def _create_left_panel(self, parent):
314298 10 ,
315299 5 ))
316300
317- # --- Checkbox UI: Create a scrollable frame for file checkboxes ---
318- list_container = ttk .Frame (file_frame , style = 'TFrame' )
319- list_container .grid (
320- row = 1 ,
321- column = 0 ,
322- sticky = 'nsew' ,
323- padx = 10 ,
324- pady = (
325- 0 ,
326- 10 ))
327- list_container .rowconfigure (0 , weight = 1 )
328-
329301 # --- Checkbox UI: Create a scrollable frame for file checkboxes ---
330302 list_container = ttk .Frame (file_frame , style = 'TFrame' )
331303 list_container .grid (
@@ -361,6 +333,8 @@ def _create_left_panel(self, parent):
361333 self .file_list_frame .bind (
362334 "<Configure>" , lambda e : file_canvas .configure (
363335 scrollregion = file_canvas .bbox ("all" )))
336+ # Bind mousewheel for easier navigation
337+ file_canvas .bind_all ("<MouseWheel>" , lambda e : file_canvas .yview_scroll (int (- 1 * (e .delta / 120 )), "units" ))
364338
365339 self .column_source_var = tk .StringVar (
366340 value = "Columns from: (no file selected)" )
@@ -531,6 +505,30 @@ def log(self, message):
531505 self .console .see ('end' )
532506 self .console .config (state = 'disabled' )
533507
508+ @staticmethod
509+ def _detect_delimiter (line ):
510+ """Infer delimiter from a header/data line. None means whitespace."""
511+ if '\t ' in line :
512+ return '\t '
513+ if ',' in line :
514+ return ','
515+ if ';' in line :
516+ return ';'
517+ return None
518+
519+ @staticmethod
520+ def _dedupe_headers (headers ):
521+ """Cp, Cp'' -> stays distinct; true duplicates get _2, _3 suffixes."""
522+ seen , out = {}, []
523+ for h in headers :
524+ if h in seen :
525+ seen [h ] += 1
526+ out .append (f"{ h } _{ seen [h ]} " )
527+ else :
528+ seen [h ] = 0
529+ out .append (h )
530+ return out
531+
534532 def launch_new_instance_handler (self ):
535533 """
536534 Handles launching a new instance of the plotter. This logic correctly
@@ -553,7 +551,7 @@ def launch_new_instance_handler(self):
553551 def browse_files (self ):
554552 filepaths = filedialog .askopenfilenames (
555553 title = "Select a data file" ,
556- filetypes = (("Data Files" , "*.csv *.dat" ), ("All files" , "*.*" ))
554+ filetypes = (("Data Files" , "*.csv *.dat *.txt " ), ("All files" , "*.*" ))
557555 )
558556 if not filepaths :
559557 return
@@ -663,154 +661,107 @@ def _set_active_file(self, filepath):
663661 self .load_file_data (filepath )
664662
665663 def _load_file_data_into_cache (self , filepath ):
666- """Loads file data into the cache without affecting the UI state (e.g., active file) ."""
664+ """Loads file data into the cache using the positional parser ."""
667665 if not filepath or not os .path .exists (filepath ):
668666 return False
669667
670668 try :
671- # Find the header row
672- header_line_index = - 1
673- with open (filepath , 'r' , encoding = 'utf-8' , errors = 'ignore' ) as f :
674- for i , line in enumerate (f ):
675- line = line .strip ()
676- if line and not line .startswith ('#' ) and (
677- ',' in line or '\t ' in line ):
678- header_line_index = i
679- break
680-
681- if header_line_index == - 1 :
682- raise ValueError ("No valid data header row found." )
683-
684- # Use genfromtxt to parse the data
685- data_array = np .genfromtxt (
686- filepath ,
687- delimiter = ',' ,
688- names = True ,
689- comments = '#' ,
690- autostrip = True ,
691- invalid_raise = False ,
692- skip_header = header_line_index )
693-
694- if not isinstance (
695- data_array ,
696- np .ndarray ) or data_array .dtype is None or data_array .dtype .names is None :
697- raise ValueError ("Could not parse data from file." )
698-
699- if data_array .size == 0 :
700- self .log (
701- f"Warning: File '{ os .path .basename (filepath )} ' contains no valid data rows." )
702-
703- headers = [name .strip () for name in data_array .dtype .names ]
704-
705- # Store data in our cache
669+ headers , delim , data_array = self ._read_data_from_file (filepath )
706670 file_info = self .file_data_cache [filepath ]
707671 file_info ['headers' ] = headers
708- file_info ['data' ] = {name : data_array [name ] for name in headers }
672+ file_info ['delimiter' ] = delim
673+ file_info ['data' ] = {h : data_array [:, j ] for j , h in enumerate (headers )}
709674 file_info ['mod_time' ] = os .path .getmtime (filepath )
710675 file_info ['size' ] = os .path .getsize (filepath )
711676
712- self .log (
713- f"Cached { len (data_array )} data points from '{ os .path .basename (filepath )} '." )
677+ self .log (f"Cached { data_array .shape [0 ]} data points from '{ os .path .basename (filepath )} '." )
714678 return True
715-
716679 except Exception as e :
717- # If loading fails, create an empty but valid cache entry to
718- # prevent errors.
719680 if filepath in self .file_data_cache :
720681 self .file_data_cache [filepath ].update (
721682 {"headers" : [], "data" : {}})
722-
723683 self .log (f"Error caching file '{ os .path .basename (filepath )} ': { e } " )
724- # We don't show a messagebox here to avoid spamming the user if they select multiple bad files.
725- # The log message is sufficient.
726684 return False
727685
728686 def _find_header_row (self , filepath ):
729- """Finds the header row index in a file."""
730- header_line_index = - 1
687+ """Returns (header_index, delimiter, headers). Raises on failure."""
688+ def is_num (tok ):
689+ try :
690+ float (tok )
691+ return True
692+ except ValueError :
693+ return False
694+
731695 with open (filepath , 'r' , encoding = 'utf-8' , errors = 'ignore' ) as f :
732696 for i , line in enumerate (f ):
733- line = line .strip ()
734- if line and not line .startswith ('#' ) and (',' in line or '\t ' in line ):
735- header_line_index = i
736- break
737- return header_line_index
738-
739- def _read_data_from_file (self , filepath , header_line_index ):
740- """Reads data from a file using numpy.genfromtxt."""
741- if header_line_index == - 1 :
742- raise ValueError (
743- "No valid data header row found. Ensure the file has a "
744- "non-commented header with comma or tab-separated columns." )
745-
697+ s = line .strip ()
698+ if not s or s .startswith ('#' ):
699+ continue
700+ delim = self ._detect_delimiter (s )
701+ tokens = [t .strip () for t in (s .split (delim ) if delim else s .split ()) if t .strip ()]
702+ if len (tokens ) < 2 :
703+ continue
704+ if not all (is_num (t ) for t in tokens ):
705+ return i , delim , self ._dedupe_headers (tokens )
706+ # First real line is numeric -> headerless file
707+ return i - 1 , delim , [f"Col{ j + 1 } " for j in range (len (tokens ))]
708+ raise ValueError ("No valid data header row found." )
709+
710+ def _read_data_from_file (self , filepath ):
711+ """Reads data by column index preserving special characters in headers."""
712+ header_idx , delim , headers = self ._find_header_row (filepath )
746713 data_array = np .genfromtxt (
747714 filepath ,
748- delimiter = ',' ,
749- names = True ,
715+ delimiter = delim ,
750716 comments = '#' ,
751717 autostrip = True ,
752718 invalid_raise = False ,
753- skip_header = header_line_index )
719+ skip_header = header_idx + 1 )
754720
755- if (not isinstance (data_array , np .ndarray ) or
756- data_array .dtype is None or
757- data_array .dtype .names is None ):
758- raise ValueError (
759- "Could not parse data. The file may be empty, have an invalid format, or contain only comments." )
760-
761- return data_array
762-
763- def _update_cache_and_ui (self , filepath , data_array ):
764- """Updates the file data cache and UI elements with new data."""
721+ data_array = np .atleast_2d (np .asarray (data_array , dtype = float ))
765722 if data_array .size == 0 :
766- self .log (
767- f"Warning: File '{ os .path .basename (filepath )} ' was loaded, but contains no valid data rows." )
768- if filepath in self .file_data_cache :
769- file_info = self .file_data_cache [filepath ]
770- file_info ['headers' ] = [
771- name .strip () for name in data_array .dtype .names ] if data_array .dtype .names else []
772- file_info ['data' ] = {h : np .array (
773- []) for h in file_info ['headers' ]}
774- data_array = np .array ([])
775-
776- headers = [name .strip () for name in data_array .dtype .names ]
777-
723+ data_array = np .empty ((0 , len (headers )))
724+ # Tolerate trailing-delimiter ghost columns
725+ if data_array .shape [1 ] > len (headers ):
726+ data_array = data_array [:, :len (headers )]
727+ elif data_array .shape [1 ] < len (headers ):
728+ raise ValueError (f"Found { data_array .shape [1 ]} data columns but { len (headers )} headers." )
729+ return headers , delim , data_array
730+
731+ def _update_cache_and_ui (self , filepath , headers , delim , data_array ):
732+ """Updates the file data cache and UI elements with new data."""
778733 file_info = self .file_data_cache [filepath ]
779734 file_info ['headers' ] = headers
780- file_info ['data' ] = {name : data_array [name ] for name in headers }
735+ file_info ['delimiter' ] = delim
736+ file_info ['data' ] = {h : data_array [:, j ] for j , h in enumerate (headers )}
781737 file_info ['mod_time' ] = os .path .getmtime (filepath )
782738 file_info ['size' ] = os .path .getsize (filepath )
783739
784740 self .x_col_cb ['values' ] = headers
785741 self .y_col_cb ['values' ] = headers
786-
787742 if len (headers ) > 1 :
788- self .x_col_cb .set (headers [0 ])
789- self .y_col_cb .set (headers [1 ])
743+ if not self . x_col_cb . get (): self .x_col_cb .set (headers [0 ])
744+ if not self . y_col_cb . get (): self .y_col_cb .set (headers [1 ])
790745 elif headers :
791- self .x_col_cb .set (headers [0 ])
792-
793- num_points = len (data_array )
794- self .log (
795- f"Loaded { num_points } data points from '{ os .path .basename (filepath )} '." )
746+ if not self .x_col_cb .get (): self .x_col_cb .set (headers [0 ])
747+ self .log (f"Loaded { data_array .shape [0 ]} data points from '{ os .path .basename (filepath )} '." )
796748
797749 def load_file_data (self , filepath ):
798750 if not filepath :
799751 self .log ("Cannot load data: No file selected." )
800752 return
801753
802- self .stop_file_watcher ()
754+ self .stop_file_watcher (log_message = False )
803755
804756 try :
805- header_line_index = self ._find_header_row (filepath )
806- data_array = self ._read_data_from_file (filepath , header_line_index )
807- self ._update_cache_and_ui (filepath , data_array )
757+ headers , delim , data_array = self ._read_data_from_file (filepath )
758+ self ._update_cache_and_ui (filepath , headers , delim , data_array )
808759
809760 except Exception as e :
810761 self ._handle_load_error (filepath , e )
811762 finally :
812763 if self .active_filepath == filepath :
813- self .start_file_watcher ()
764+ self .start_file_watcher (log_message = False )
814765 self .plot_data ()
815766
816767 def append_file_data (self ):
@@ -819,7 +770,7 @@ def append_file_data(self):
819770 self .active_filepath ):
820771 return
821772
822- self .stop_file_watcher ()
773+ self .stop_file_watcher (log_message = False )
823774 file_info = self .file_data_cache .get (self .active_filepath )
824775
825776 if not file_info or 'size' not in file_info :
@@ -870,10 +821,11 @@ def _parse_and_append_new_data(self, new_lines, file_info):
870821 if not new_lines :
871822 return 0
872823
873- reader = csv . reader ( new_lines )
824+ delim = file_info . get ( 'delimiter' )
874825 new_data = {h : [] for h in file_info ['headers' ]}
875826 appended_count = 0
876- for row in reader :
827+ for line in new_lines :
828+ row = [t .strip () for t in (line .split (delim ) if delim else line .split ()) if t .strip ()]
877829 if len (row ) != len (file_info ['headers' ]):
878830 continue
879831 for i , header in enumerate (file_info ['headers' ]):
@@ -982,6 +934,12 @@ def _finalize_plot(self, x_col, y_col, selected_filepaths):
982934 self .ax_main .set_xlabel (x_col )
983935 self .ax_main .set_ylabel (y_col )
984936
937+ # Log-scale with non-positive data warnings
938+ if self .x_log_var .get () and np .any (raw_x <= 0 ):
939+ self .log (f"Warning: X log-scale active for '{ os .path .basename (filepath )} ', but non-positive data exists." )
940+ if self .y_log_var .get () and np .any (raw_y <= 0 ):
941+ self .log (f"Warning: Y log-scale active for '{ os .path .basename (filepath )} ', but non-positive data exists." )
942+
985943 if len (selected_filepaths ) == 1 :
986944 self .ax_main .set_title (
987945 os .path .basename (
@@ -991,7 +949,6 @@ def _finalize_plot(self, x_col, y_col, selected_filepaths):
991949 self .ax_main .set_title (
992950 f"{ y_col } vs. { x_col } " ,
993951 fontweight = 'bold' )
994- self .figure .tight_layout ()
995952
996953 def _handle_load_error (self , filepath , e ):
997954 """Handles errors during file loading."""
@@ -1000,7 +957,6 @@ def _handle_load_error(self, filepath, e):
1000957 "path" : filepath , "headers" : [], "data" : {}}
1001958 self .column_source_var .set ("Columns from: (no file selected)" )
1002959 self .active_filepath = None
1003- self .plot_data ()
1004960 self .x_col_cb .set ('' )
1005961 self .y_col_cb .set ('' )
1006962 self .x_col_cb ['values' ] = []
@@ -1018,18 +974,20 @@ def toggle_live_update(self):
1018974 else :
1019975 self .stop_file_watcher ()
1020976
1021- def start_file_watcher (self ):
1022- self .stop_file_watcher () # Ensure no multiple watchers are running
977+ def start_file_watcher (self , log_message = True ):
978+ self .stop_file_watcher (log_message = False )
1023979 if self .live_update_var .get () and self .active_filepath :
1024- self .log ("Live update enabled. Watching for file changes..." )
980+ if log_message :
981+ self .log ("Live update enabled. Watching for file changes..." )
1025982 self .file_watcher_job = self .root .after (
1026983 1000 , self .check_for_updates )
1027984
1028- def stop_file_watcher (self ):
985+ def stop_file_watcher (self , log_message = True ):
1029986 if self .file_watcher_job :
1030987 self .root .after_cancel (self .file_watcher_job )
1031988 self .file_watcher_job = None
1032- self .log ("Live update disabled." )
989+ if log_message :
990+ self .log ("Live update disabled." )
1033991
1034992 def check_for_updates (self ):
1035993 if not self .active_filepath or not self .live_update_var .get (
0 commit comments