From 72ca79a97ce466a691a51741e30a0eb165d84b17 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:19:35 -0400 Subject: [PATCH 01/66] Update imports I changed to datetime as dt so we can do isinstance(value, dt.date) before isinstance(value, date) or isinstance(value, datetime.date) doesn't work --- pysimplesql/pysimplesql.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index f6120eca..a840a807 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -55,18 +55,20 @@ from __future__ import annotations # docstrings import asyncio +import calendar import contextlib +import datetime as dt import enum import functools +import itertools import logging import math import os.path import queue -import threading # threaded popup +import threading import tkinter as tk import tkinter.font as tkfont -from datetime import date, datetime -from time import sleep, time # threaded popup +from time import sleep, time from tkinter import ttk from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypedDict, Union From b1b1cf981ac498794cfa8ef68b6297b3374b3ef5 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:22:56 -0400 Subject: [PATCH 02/66] Fix how we handle sg.Text This fills in sg.Text if it has a fk-relationship Don't compare it in records_changed Add an empty col in place of the required_marker --- pysimplesql/pysimplesql.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index a840a807..169ff664 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -905,6 +905,10 @@ def records_changed(self, column: str = None, recursive=True) -> bool: if column is not None and mapped.column != column: continue + # if sg.Text + if type(mapped.element) is sg.Text: + continue + # don't check if there aren't any rows. Fixes checkbox = '' when no # rows. if not len(self.frm[mapped.table].rows.index): @@ -1755,6 +1759,10 @@ def save_record( # Propagate GUI data back to the stored current_row for mapped in [m for m in self.frm.element_map if m.dataset == self]: + # skip if sg.Text + if type(mapped.element) is sg.Text: + continue + # convert the data into the correct type using the domain in ColumnInfo element_val = self.column_info[mapped.column].cast(mapped.element.get()) @@ -3860,7 +3868,22 @@ def update_fields( None, ) # and update element - mapped.element.update(values=combo_vals) + mapped.element.update(values=combo_vals, readonly=True) + + elif type(mapped.element) is sg.Text: + rels = Relationship.get_relationships(mapped.dataset.table) + found = False + # try to get description of linked if foreign-key + for rel in rels: + if mapped.column == rel.fk_column: + updated_val = mapped.dataset.frm[ + rel.parent_table + ].get_description_for_pk(mapped.dataset[mapped.column]) + found = True + break + if not found: + updated_val = mapped.dataset[mapped.column] + mapped.element.update("") elif type(mapped.element) is sg.PySimpleGUI.Table: # Tables use an array of arrays for values. Note that the headings @@ -3887,7 +3910,6 @@ def update_fields( elif type(mapped.element) in [ sg.PySimpleGUI.InputText, sg.PySimpleGUI.Multiline, - sg.PySimpleGUI.Text, ]: # Update the element in the GUI # For text objects, lets clear it first... @@ -4956,11 +4978,11 @@ def field( ) if element.__name__ == "Text": # don't show markers for sg.Text if no_label: - layout = [[layout_element]] + layout = [[sg.Text(" "), layout_element]] elif label_above: - layout = [[layout_label], [layout_element]] + layout = [[layout_label], [sg.Text(" "), layout_element]] else: - layout = [[layout_label, layout_element]] + layout = [[layout_label, sg.Text(" "), layout_element]] else: if no_label: layout = [[layout_marker, layout_element]] @@ -5002,7 +5024,6 @@ def field( ) ) # return layout - # TODO: Does this actually need wrapped in a sg.Col??? return sg.Col(layout=layout, pad=(0, 0)) From 41938b037760dafe46f3edc6120590dfde0617b6 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:25:42 -0400 Subject: [PATCH 03/66] Subtle changes to value_changed Go ahead and cast the table_value, since we maybe comparing dates, and table_value will be a str since Sqlite stores them as such. --- pysimplesql/pysimplesql.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 169ff664..9742ddec 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -959,7 +959,7 @@ def records_changed(self, column: str = None, recursive=True) -> bool: # TODO: How to type-hint this return? def value_changed( self, column_name: str, old_value, new_value, is_checkbox: bool - ) -> Union[Any, bool]: + ) -> Union[Any, Boolean]: """ Verifies if a new value is different from an old value and returns the cast value ready to be inserted into a database. @@ -982,6 +982,7 @@ def value_changed( if col["name"] == column_name: new_value = col.cast(new_value) element_val = new_value + table_val = col.cast(table_val) break if is_checkbox: @@ -994,9 +995,9 @@ def value_changed( table_val = "" # Strip trailing whitespace from strings - if type(table_val) is str: + if isinstance(table_val, str): table_val = table_val.rstrip() - if type(element_val) is str: + if isinstance(element_val, str): element_val = element_val.rstrip() # Make the comparison @@ -4982,7 +4983,7 @@ def field( elif label_above: layout = [[layout_label], [sg.Text(" "), layout_element]] else: - layout = [[layout_label, sg.Text(" "), layout_element]] + layout = [[layout_label, layout_element]] else: if no_label: layout = [[layout_marker, layout_element]] From 7a5dd1b5d8992925ae83074c798b7ca293040d2f Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:27:19 -0400 Subject: [PATCH 04/66] Rewritten search This fills in description_column if col has fk-rels. Also simplified logic --- pysimplesql/pysimplesql.py | 82 ++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 9742ddec..509d4d43 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1025,7 +1025,6 @@ def prompt_save( `PROMPT_DISCARDED`, or `PROMPT_NONE`. """ # Return False if there is nothing to check or _prompt_save is False - # TODO: children too? if self.current_index is None or not self.row_count or not self._prompt_save: return PROMPT_SAVE_NONE @@ -1393,42 +1392,55 @@ def search( ): return None - # First lets make a search order.. TODO: remove this hard coded garbage if self.row_count: logger.debug(f"DEBUG: {self.search_order} {self.rows.columns[0]}") - for field in self.search_order: - # Perform a search for str, from the current position to the end and back by - # creating a list of all indexes - for i in list(range(self.current_index + 1, self.row_count)) + list( - range(0, self.current_index) - ): - if ( - field in list(self.rows.columns) - and search_string.lower() in str(self.rows.iloc[i][field]).lower() - ): - old_index = self.current_index - if i == old_index: - return None - self.current_index = i - if update_elements: - self.frm.update_elements(self.key) - if requery_dependents: - self.requery_dependents() - - # callback - if "after_search" in self.callbacks and not self.callbacks[ - "after_search" - ](self.frm, self.frm.window): - self.current_index = old_index - self.frm.update_elements(self.key) - self.requery_dependents() - return SEARCH_ABORTED - - # callback - if "record_changed" in self.callbacks: - self.callbacks["record_changed"](self.frm, self.frm.window) - - return SEARCH_RETURNED + + # fill in descriptions for cols in search_order + rels = Relationship.get_relationships(self.table) + + def process_row(row): + for col in self.search_order: + for rel in rels: + if col == rel.fk_column: + # change value in row to below + value = self.frm[rel.parent_table].get_description_for_pk( + row[col] + ) + row[col] = value + return row + return None + + rows = self.rows.apply(process_row, axis=1) + + for column in self.search_order: + # search through processed rows, looking for search_string + result = rows[rows[column].str.contains(search_string, case=False)] + if not result.empty: + old_index = self.current_index + # grab the first result + pk = result.iloc[0][self.pk_column] + if pk == self[self.pk_column]: + return None + self.set_by_pk( + pk=pk, + update_elements=update_elements, + requery_dependents=requery_dependents, + ) + + # callback + if "after_search" in self.callbacks and not self.callbacks[ + "after_search" + ](self.frm, self.frm.window): + self.current_index = old_index + self.frm.update_elements(self.key) + self.requery_dependents() + return SEARCH_ABORTED + + # callback + if "record_changed" in self.callbacks: + self.callbacks["record_changed"](self.frm, self.frm.window) + + return SEARCH_RETURNED return SEARCH_FAILED # If we have made it here, then it was not found! # sg.Popup('Search term "'+str+'" not found!') From c31f4f0ef6b7215e9cf6161fe7ec1fbdd7c094c1 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:27:40 -0400 Subject: [PATCH 05/66] Add try-except to set_by_pk --- pysimplesql/pysimplesql.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 509d4d43..8768f74e 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1525,7 +1525,14 @@ def set_by_pk( # Get the numerical index of where the primary key is located. # If the pk value can't be found, set to the last index - idx = [i for i, value in enumerate(self.rows[self.pk_column]) if value == pk] + try: + idx = [ + i for i, value in enumerate(self.rows[self.pk_column]) if value == pk + ] + except IndexError: + idx = None + logger.debug("Error finding pk!") + idx = idx[0] if idx else self.row_count self.set_by_index( From c0115f99ec07bf9d85d8f5e5c205e97bc5305420 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:28:14 -0400 Subject: [PATCH 06/66] Update set_current to write an event to the main loop --- pysimplesql/pysimplesql.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 8768f74e..411d0271 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1563,7 +1563,9 @@ def get_current( return default return default - def set_current(self, column: str, value: Union[str, int]) -> None: + def set_current( + self, column: str, value: Union[str, int], write_event: bool = False + ) -> None: """ Set the value for the supplied column in the current row, making a backup if needed. @@ -1573,11 +1575,28 @@ def set_current(self, column: str, value: Union[str, int]) -> None: :param column: The column you want to set the value for :param value: A value to set the current record's column to + :param write_event: (optional) If True, writes an event to PySimpleGui + as `current_row_updated`. :returns: None """ logger.debug(f"Setting current record for {self.key}.{column} = {value}") self.backup_current_row() self.rows.loc[self.rows.index[self.current_index], column] = value + if write_event: + self.frm.window.write_event_value( + "current_row_updated", + { + "frm_reference": self.frm, + "data_key": self.key, + "column": column, + "value": value, + }, + ) + # # TODO: I'd like to talk about extending callbacks to include + # # data_key (if callback is for a specific data_key) + # if "current_row_updated" in dataset.callbacks: + # dataset.callbacks["current_row_updated"]( + # self.frm, self.frm.window, self.key) def get_keyed_value( self, value_column: str, key_column: str, key_value: Union[str, int] From 423febd2c7dd0044090046784a475be6eaddf125 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:28:40 -0400 Subject: [PATCH 07/66] Removing some outdated comments --- pysimplesql/pysimplesql.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 411d0271..12844db3 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1692,10 +1692,6 @@ def insert_record( the insert. :returns: None """ - # todo: you don't add a record if there isn't a parent!!! - # todo: this is currently filtered out by enabling of the element, but it should - # be filtered here too! - # todo: bring back the values parameter? # prompt_save if ( not skip_prompt_save @@ -1787,7 +1783,7 @@ def save_record( return SAVE_NONE + SHOW_MESSAGE # Work with a copy of the original row and transform it if needed - # Note that while saving, we are working with just the current row of data, + # While saving, we are working with just the current row of data, # unless it's 'keyed' via ?/= current_row = self.get_current_row().copy() From 2caf2b7d03f4553f0525f36b0166521eb6f95089 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:30:30 -0400 Subject: [PATCH 08/66] Fix nan and numpy types showing in sg.Table / Add supported for generated columns --- pysimplesql/pysimplesql.py | 45 +++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 12844db3..4dcef554 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1834,11 +1834,12 @@ def save_record( current_row[mapped.column] = element_val # create diff of columns if not virtual - new_dict = dict(current_row.items()) + new_dict = current_row.fillna("").to_dict() + if self.row_is_virtual(): changed_row_dict = new_dict else: - old_dict = dict(self.get_original_current_row().items()) + old_dict = self.get_original_current_row().fillna("").to_dict() changed_row_dict = { key: new_dict[key] for key in new_dict @@ -1871,6 +1872,13 @@ def save_record( if self.transform is not None: self.transform(self, changed_row_dict, TFORM_ENCODE) + # delete generated rows + changed_row_dict = { + col: value + for col, value in changed_row_dict.items() + if self.column_info[col] and not self.column_info[col]["generated"] + } + # Save or Insert the record as needed if keyed_queries is not None: # Now execute all the saved queries from earlier @@ -1917,7 +1925,7 @@ def save_record( if result.attrs["lastrowid"] is not None else self.get_current_pk() ) - current_row[self.pk_column] = pk + self.set_current(self.pk_column, pk, write_event=False) # then update the current row data self.rows.iloc[self.current_index] = current_row @@ -2358,6 +2366,13 @@ def table_values( rels = Relationship.get_relationships(self.table) + bool_columns = [ + column + for column in columns + if self.column_info[column] + and self.column_info[column]["domain"] in ["BOOLEAN"] + ] + def process_row(row): lst = [] pk = row[pk_column] @@ -2368,8 +2383,14 @@ def process_row(row): # only loop through passed-in columns for col in columns: - is_fk_column = any(rel.fk_column == col for rel in rels) - if is_fk_column: + if col in bool_columns and themepack.display_boolean_as_checkbox: + row[col] = ( + themepack.checkbox_true + if checkbox_to_bool(row[col]) + else themepack.checkbox_false + ) + lst.append(row[col]) + elif any(rel.fk_column == col for rel in rels): for rel in rels: if col == rel.fk_column: lst.append( @@ -2383,7 +2404,7 @@ def process_row(row): return TableRow(pk, lst) - return self.rows.apply(process_row, axis=1) + return self.rows.fillna("").apply(process_row, axis=1) def column_likely_in_selector(self, column: str) -> bool: """ @@ -6483,6 +6504,7 @@ def __init__( default: None, pk: bool, virtual: bool = False, + generated: bool = False, ): self._column = { "name": name, @@ -6491,6 +6513,7 @@ def __init__( "default": default, "pk": pk, "virtual": virtual, + "generated": generated, } def __str__(self): @@ -7601,7 +7624,7 @@ def get_tables(self): def column_info(self, table): # Return a list of column names - q = f"PRAGMA table_info({self.quote_table(table)})" + q = f"PRAGMA table_xinfo({self.quote_table(table)})" rows = self.execute(q, silent=True) names = [] col_info = ColumnInfo(self, table) @@ -7613,9 +7636,15 @@ def column_info(self, table): notnull = row["notnull"] default = row["dflt_value"] pk = row["pk"] + generated = row["hidden"] col_info.append( Column( - name=name, domain=domain, notnull=notnull, default=default, pk=pk + name=name, + domain=domain, + notnull=notnull, + default=default, + pk=pk, + generated=generated, ) ) From 8dd585e5bcf15a676964d012fa6398d027c85ebb Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:32:19 -0400 Subject: [PATCH 09/66] Safeguards for closing popups. --- pysimplesql/pysimplesql.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 4dcef554..528b72b1 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2561,6 +2561,8 @@ def quick_editor( break logger.debug(f"This event ({event}) is not yet handled.") + if quick_frm.popup.popup_info: + quick_frm.popup.popup_info.close() quick_win.close() self.requery() self.frm.update_elements() @@ -2922,6 +2924,8 @@ def close(self, reset_keygen: bool = True): """ # First delete the dataset associated DataSet.purge_form(self, reset_keygen) + if self.popup.popup_info: + self.popup.popup_info.close() self.driver.close() def bind(self, win: sg.Window) -> None: @@ -2944,7 +2948,7 @@ def bind(self, win: sg.Window) -> None: self.update_elements() # Creating cell edit instance, even if we arn't going to use it. self._celledit = _CellEdit(self) - self.window.TKroot.bind("", self._celledit) + self.window.TKroot.bind("", self._celledit, "+") self._liveupdate = _LiveUpdate(self) if self.live_update: self.set_live_update(enable=True) @@ -4482,6 +4486,8 @@ def info( if display_message: msg_lines = msg.splitlines() layout = [[sg.Text(line, font="bold")] for line in msg_lines] + if self.popup_info: + self.popup_info.close() self.popup_info = sg.Window( title=title, layout=layout, @@ -4499,7 +4505,8 @@ def _auto_close(self): """ Use in a tk.after to automatically close the popup_info. """ - self.popup_info.close() + if self.popup_info: + self.popup_info.close() class ProgressBar: @@ -5038,7 +5045,7 @@ def field( elif label_above: layout = [[layout_label], [sg.Text(" "), layout_element]] else: - layout = [[layout_label, layout_element]] + layout = [[layout_label, sg.Text(" "), layout_element]] else: if no_label: layout = [[layout_marker, layout_element]] From 5f2b67645da5cb7e6ee9b9e535fd74d58b4396c2 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:33:26 -0400 Subject: [PATCH 10/66] Updates to casting / adding dt. datetime --- pysimplesql/pysimplesql.py | 48 +++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 528b72b1..42625cac 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -6570,7 +6570,14 @@ def cast(self, value: any) -> any: # Integer type casting elif domain in ["INT", "INTEGER", "BOOLEAN"]: try: - value = int(value) + if isinstance(value, int): + pass + elif isinstance(value, ElementRow): + value = int(value) + elif type(value) is str: + value = float(value) + if value == int(value): + value = int(value) except (ValueError, TypeError): value = str(value) @@ -6584,8 +6591,10 @@ def cast(self, value: any) -> any: # Date casting elif domain == "DATE": try: - value = datetime.strptime(value, "%Y-%m-%d").date() - # TODO: ValueError for sqlserver returns date(): 2023-04-27 15:31:13.170000 + if not isinstance(value, dt.date): + value = dt.datetime.strptime(value, "%Y-%m-%d").date() + # TODO: ValueError for sqlserver returns: + # date(): 2023-04-27 15:31:13.170000 except (TypeError, ValueError) as e: logger.debug( f"Unable to cast {value} to a datetime.date object. " @@ -6600,7 +6609,7 @@ def cast(self, value: any) -> any: parsed = False for timestamp_format in timestamp_formats: try: - value = datetime.strptime(value, timestamp_format) + value = dt.datetime.strptime(value, timestamp_format) # value = dt.datetime() parsed = True break @@ -6614,12 +6623,10 @@ def cast(self, value: any) -> any: value = str(value) # other date/time casting - elif domain in [ - "TIME", - "DATETIME", - ]: # TODO: i'm sure there is a lot of work to do here + # TODO: i'm sure there is a lot of work to do here + elif domain in ["TIME", "DATETIME"]: try: - value = datetime.date(value) + value = dt.date(value) except TypeError: print( "Unable to case datetime/time/timestamp. Casting to string instead." @@ -6681,10 +6688,10 @@ def __init__(self, driver: SQLDriver, table: str): "FLOAT": 0.0, "DECIMAL": 0.0, "BOOLEAN": 0, - "TIME": lambda x: datetime.now().strftime("%H:%M:%S"), - "DATE": lambda x: date.today().strftime("%Y-%m-%d"), - "TIMESTAMP": lambda x: datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "DATETIME": lambda x: datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "TIME": lambda x: dt.datetime.now().strftime("%H:%M:%S"), + "DATE": lambda x: dt.date.today().strftime("%Y-%m-%d"), + "TIMESTAMP": lambda x: dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "DATETIME": lambda x: dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } super().__init__() @@ -6856,10 +6863,11 @@ def get_virtual_names(self) -> List[str]: def _contains_key_value_pair(self, key, value): # used by __contains__ return any(key in d and d[key] == value for d in self) + # TODO: check if something looks like a statement for complex defaults? Regex? @staticmethod def _looks_like_function( s: str, - ): # TODO: check if something looks like a statement for complex defaults? Regex? + ): # check if the string is empty if not s: return False @@ -7084,7 +7092,7 @@ def relationships(self): # based on specifics of the database # --------------------------------------------------------------------- # This is a generic way to estimate the next primary key to be generated. - # Note that this is not always a reliable way, as manual inserts which assign a + # This is not always a reliable way, as manual inserts which assign a # primary key value don't always update the sequencer for the given database. This # is just a default way to "get things working", but the best bet is to override # this in the derived class and get the value right from the sequencer. @@ -7248,9 +7256,7 @@ def generate_query( f' {dataset.order_clause if order_clause else ""}' ) - def delete_record( - self, dataset: DataSet, cascade=True - ): # TODO: get ON DELETE CASCADE from db + def delete_record(self, dataset: DataSet, cascade=True): # Get data for query table = self.quote_table(dataset.table) pk_column = self.quote_column(dataset.pk_column) @@ -8680,16 +8686,16 @@ def execute( timestamp_format = "%Y-%m-%dT%H:%M:%S.%f" else: timestamp_format = "%Y-%m-%dT%H:%M:%S" - dt_value = datetime.strptime(timestamp_str, timestamp_format) + dt_value = dt.datetime.strptime(timestamp_str, timestamp_format) value = dt_value.strftime("%Y-%m-%d") elif isinstance(value, jpype.JPackage("java").sql.Date): date_str = value.toString() date_format = "%Y-%m-%d" - value = datetime.strptime(date_str, date_format).date() + value = dt.datetime.strptime(date_str, date_format).date() elif isinstance(value, jpype.JPackage("java").sql.Time): time_str = value.toString() time_format = "%H:%M:%S" - value = datetime.strptime(time_str, time_format).time() + value = dt.datetime.strptime(time_str, time_format).time() elif value is not None: value = value # TODO: More conversions? From 7dc33b659a406a6e7ee8c3be2d887bef1267f7ea Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:35:15 -0400 Subject: [PATCH 11/66] Add save_enabled to sg.Table headings (first column) --- pysimplesql/pysimplesql.py | 105 ++++++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 42625cac..fff90178 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -3337,9 +3337,9 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: # 2 Run TableHeading.update_headings() with the: # Table element, sort_column, sort_reverse # 3 Run update_elements() to see the changes - table_heading.enable_sorting( + table_heading.enable_heading_function( element, - _SortCallbackWrapper(self, data_key), + _HeadingCallback(self, data_key), ) else: @@ -3890,17 +3890,14 @@ def update_fields( updated_val = mapped.dataset.get_keyed_value( mapped.column, mapped.where_column, mapped.where_value ) - if type(mapped.element) in [ - sg.PySimpleGUI.CBox - ]: # TODO, may need to add more?? + # TODO, may need to add more?? + if type(mapped.element) in [sg.PySimpleGUI.CBox]: updated_val = checkbox_to_bool(updated_val) elif type(mapped.element) is sg.PySimpleGUI.Combo: # Update elements with foreign dataset first # This will basically only be things like comboboxes - # TODO: move this to only compute if something else changes? # Find the relationship to determine which table to get data from - # TODO this should be get_relationships_for_data? combo_vals = mapped.dataset.combobox_values(mapped.column) if not combo_vals: logger.info( @@ -4048,9 +4045,8 @@ def update_selectors( lst = [] for _, r in dataset.rows.iterrows(): if e["where_column"] is not None: - if str(r[e["where_column"]]) == str( - e["where_value"] - ): # TODO: Kind of a hackish way to check for equality. + # TODO: Kind of a hackish way to check for equality. + if str(r[e["where_column"]]) == str(e["where_value"]): lst.append( ElementRow(r[pk_column], r[description_column]) ) @@ -4061,7 +4057,11 @@ def update_selectors( ElementRow(r[pk_column], r[description_column]) ) - element.update(values=lst, set_to_index=dataset.current_index) + element.update( + values=lst, + set_to_index=dataset.current_index, + readonly=True, + ) # set vertical scroll bar to follow selected element # (for listboxes only) @@ -4223,7 +4223,7 @@ def update_element_states( continue element = mapped.element if type(element) in [ - sg.PySimpleGUI.InputText, + sg.PySimpleGUI.Input, sg.PySimpleGUI.MLine, sg.PySimpleGUI.Combo, sg.PySimpleGUI.Checkbox, @@ -5600,21 +5600,26 @@ def selector( kwargs["select_mode"] = sg.TABLE_SELECT_MODE_BROWSE kwargs["justification"] = "left" + # Make an empty list of values + vals = [[""] * len(kwargs["headings"])] + # Create a narrow column for displaying a * character for virtual rows. - # This will have to be the 2nd column right after the pk - kwargs["headings"].insert(0, "") + # This will be the 1st column kwargs["visible_column_map"].insert(0, 1) if "col_widths" in kwargs: - kwargs["col_widths"].insert(0, 2) - - # Make an empty list of values - vals = [[""] * len(kwargs["headings"])] + kwargs["col_widths"].insert(0, themepack.unsaved_column_width) # Change the headings parameter to be a list so # the heading doesn't display dicts when it first loads # The TableHeadings instance is already stored in metadata - if kwargs["headings"].__class__.__name__ == "TableHeadings": + if isinstance(kwargs["headings"], TableHeadings): + if kwargs["headings"].save_enable: + kwargs["headings"].insert(0, themepack.unsaved_column_header) + else: + kwargs["headings"].insert(0, "") kwargs["headings"] = kwargs["headings"].heading_names() + else: + kwargs["headings"].insert(0, "") layout = element(values=vals, key=key, metadata=meta, **kwargs) else: @@ -5636,27 +5641,38 @@ class TableHeadings(list): # store our instances instances = [] - def __init__(self, sort_enable: bool = True, edit_enable: bool = False) -> None: + def __init__( + self, + sort_enable: bool = True, + edit_enable: bool = False, + save_enable: bool = False, + ) -> None: """ Create a new TableHeadings object. :param sort_enable: True to enable sorting by heading column - :param edit_enable: True to enable editing cells. If cell editing is enabled, - any accepted edits will immediately push to the associated field element. - In addition, editing the set description column will trigger the update of - all comboboxes. + :param edit_enable: Enables cell editing if True. Accepted edits update both + `sg.Table` and associated `field` element. + :param save_enable: Enables saving record by double-clicking unsaved marker col. :returns: None """ self.sort_enable = sort_enable self.edit_enable = edit_enable + self.save_enable = save_enable self._width_map = [] self._visible_map = [] + self.readonly_columns = [] # Store this instance in the master list of instances TableHeadings.instances.append(self) def add_column( - self, column: str, heading_column: str, width: int, visible: bool = True + self, + column: str, + heading_column: str, + width: int, + visible: bool = True, + readonly: bool = False, ) -> None: """ Add a new heading column to this TableHeading object. Columns are added in the @@ -5669,11 +5685,15 @@ def add_column( :param visible: True if the column is visible. Typically, the only hidden column would be the primary key column if any. This is also useful if the `DataSet.rows` DataFrame has information that you don't want to display. + :param readonly: Indicates if the column is read-only when + `TableHeading.edit_enable` is True. :returns: None """ self.append({"heading": heading_column, "column": column}) self._width_map.append(width) self._visible_map.append(visible) + if readonly: + self.readonly_columns.append(column) def heading_names(self) -> List[str]: """ @@ -5745,9 +5765,10 @@ def update_headings( x["heading"] += asc if sort_order == SORT_ASC else desc element.Widget.heading(i, text=x["heading"], anchor="w") - def enable_sorting(self, element: sg.Table, fn: callable) -> None: + def enable_heading_function(self, element: sg.Table, fn: callable) -> None: """ - Enable the sorting callbacks for each column index. + Enable the sorting callbacks for each column index, or saving by click the + unsaved changes column Note: Not typically used by the end user. Called from `Form.auto_map_elements()` :param element: The PySimpleGUI Table element associated with this TableHeading @@ -5759,21 +5780,23 @@ def enable_sorting(self, element: sg.Table, fn: callable) -> None: for i in range(len(self)): if self[i]["column"] is not None: element.widget.heading( - i, command=functools.partial(fn, self[i]["column"]) + i, command=functools.partial(fn, self[i]["column"], False) ) - self.update_headings(element) + self.update_headings(element) + if self.save_enable: + element.widget.heading(0, command=functools.partial(fn, None, save=True)) def insert(self, idx, heading_column: str, column: str = None, *args, **kwargs): super().insert(idx, {"heading": heading_column, "column": column}) -class _SortCallbackWrapper: +class _HeadingCallback: - """Internal class used when sg.Table column headers are clicked.""" + """Internal class used when sg.Table column headings are clicked.""" def __init__(self, frm_reference: Form, data_key: str): """ - Create a new _SortCallbackWrapper object. + Create a new _HeadingCallback object. :param frm_reference: `Form` object :param data_key: `DataSet` key @@ -5782,8 +5805,16 @@ def __init__(self, frm_reference: Form, data_key: str): self.frm: Form = frm_reference self.data_key = data_key - def __call__(self, column): - self.frm[self.data_key].sort_cycle(column, self.data_key, update_elements=True) + def __call__(self, column, save): + if save: + self.frm[self.data_key].save_record() + # force a timeout, without this + # info popup creation broke pysimplegui events, weird! + self.frm.window.read(timeout=1) + else: + self.frm[self.data_key].sort_cycle( + column, self.data_key, update_elements=True + ) class _CellEdit: @@ -6211,8 +6242,10 @@ class ThemePack: # fmt: on # Markers # ---------------------------------------- - "marker_unsaved": "\u2731", - "marker_required": "\u2731", + "marker_unsaved": "✱", + "unsaved_column_header": "💾", + "unsaved_column_width": 3, + "marker_required": "✱", "marker_required_color": "red2", # Sorting icons # ---------------------------------------- From 61a74a7c842bff3212fcd4e910499b2cab29c26b Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:36:15 -0400 Subject: [PATCH 12/66] Date Datepicker, and displaying boolean as Checkboxes --- pysimplesql/pysimplesql.py | 484 +++++++++++++++++++++++++++---------- 1 file changed, 357 insertions(+), 127 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index fff90178..3eccf5b2 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -201,6 +201,7 @@ TK_ENTRY = "Entry" TK_COMBOBOX = "Combobox" TK_CHECKBUTTON = "Checkbutton" +TK_DATEPICKER = "Datepicker" class Boolean(enum.Flag): @@ -5855,9 +5856,6 @@ def edit(self, event): if not element: return - # found a table we can edit, don't allow another double-click - self.active_edit = True - # get table_headings table_heading = element.metadata["TableHeading"] @@ -5865,128 +5863,169 @@ def edit(self, event): columns = table_heading.columns() column = columns[col_idx - 1] - # make sure it's not the marker column or pk_column - if col_idx > 0 and column != self.frm[data_key].pk_column: - # use table_element to distinguish - table_element = element.Widget - root = table_element.master - - # get cell text, coordinates, width and height - text = table_element.item(row, "values")[col_idx] - x, y, width, height = table_element.bbox(row, col_idx) - - # see if we should use a combobox - combobox_values = self.frm[data_key].combobox_values(column) - - if combobox_values: - widget_type = TK_COMBOBOX - width = ( - width - if width >= themepack.combobox_min_width - else themepack.combobox_min_width - ) + # use table_element to distinguish + table_element = element.Widget + root = table_element.master - # or a checkbox - elif self.frm[data_key].column_info[column]["domain"] in ["BOOLEAN"]: - widget_type = TK_CHECKBUTTON - width = ( - width - if width >= themepack.checkbox_min_width - else themepack.checkbox_min_width - ) + # get cell text, coordinates, width and height + text = table_element.item(row, "values")[col_idx] + x, y, width, height = table_element.bbox(row, col_idx) - # else, its a normal ttk.entry - else: - widget_type = TK_ENTRY - width = ( - width - if width >= themepack.text_min_width - else themepack.text_min_width - ) + # return early due to following conditions: + if col_idx == 0: + return - # float a frame over the cell - frame = ttk.Frame(root) - frame.place(x=x, y=y, anchor="nw", width=width, height=height) + if column in table_heading.readonly_columns: + logger.debug(f"{column} is readonly") + return - # setup the widgets - # ------------------ + if column == self.frm[data_key].pk_column: + logger.debug(f"{column} is pk_column") + return - # checkbutton - # need to use tk.IntVar for checkbox - if widget_type == TK_CHECKBUTTON: - field_var = tk.IntVar() - field_var.set(text) - self.field = ttk.Checkbutton(frame, variable=field_var) - else: - # create tk.StringVar for combo/entry - field_var = tk.StringVar() - field_var.set(text) - - # entry - if widget_type == TK_ENTRY: - self.field = ttk.Entry(frame, textvariable=field_var, justify="left") - - # combobox - if widget_type == TK_COMBOBOX: - self.field = ttk.Combobox(frame, textvariable=field_var, justify="left") - self.field["values"] = combobox_values - self.field.bind("", self.combo_configure) - - # bind text to Return (for save), and Escape (for discard) - # event is discarded - accept_dict = { - "data_key": data_key, - "table_element": table_element, - "row": row, - "column": column, - "col_idx": col_idx, - "combobox_values": combobox_values, - "widget_type": widget_type, - "field_var": field_var, - } + if self.frm[data_key].column_info[column]["generated"]: + logger.debug(f"{column} is a generated column") + return - self.field.bind( - "", - lambda event: self.accept(**accept_dict), + if not table_heading.edit_enable: + logger.debug("This Table element does not allow editing") + return + + # else, we can continue: + self.active_edit = True + + # see if we should use a combobox + combobox_values = self.frm[data_key].combobox_values(column) + + if combobox_values: + widget_type = TK_COMBOBOX + width = ( + width + if width >= themepack.combobox_min_width + else themepack.combobox_min_width ) - self.field.bind("", lambda event: self.destroy()) - - if themepack.use_cell_buttons: - # buttons - self.accept_button = tk.Button( - frame, - text="\u2714", - foreground="green", - relief=tk.GROOVE, - command=lambda: self.accept(**accept_dict), - ) - self.cancel_button = tk.Button( - frame, - text="\u274E", - foreground="red", - relief=tk.GROOVE, - command=lambda: self.destroy(), - ) - # pack buttons - self.cancel_button.pack(side="right") - self.accept_button.pack(side="right") - - # have entry use remaining space - self.field.pack(side="left", expand=True, fill="both") - - # select text and focus to begin with - if widget_type != TK_CHECKBUTTON: - self.field.select_range(0, tk.END) - self.field.focus_force() - - # bind single-clicks - self.destroy_bind = self.frm.window.TKroot.bind( - "", - lambda event: self.single_click_callback(event, accept_dict), + + # or a checkbox + elif self.frm[data_key].column_info[column]["domain"] in ["BOOLEAN"]: + widget_type = TK_CHECKBUTTON + width = ( + width + if width >= themepack.checkbox_min_width + else themepack.checkbox_min_width + ) + + # or a date + elif self.frm[data_key].column_info[column]["domain"] in ["DATE"]: + text = self.frm[data_key].column_info[column].cast(text) + widget_type = TK_DATEPICKER + width = ( + width + if width >= themepack.datepicker_min_width + else themepack.datepicker_min_width ) + + # else, its a normal ttk.entry + else: + widget_type = TK_ENTRY + width = ( + width if width >= themepack.text_min_width else themepack.text_min_width + ) + + # float a frame over the cell + frame = tk.Frame(root) + frame.place(x=x, y=y, anchor="nw", width=width, height=height) + + # setup the widgets + # ------------------ + + # checkbutton + # need to use tk.IntVar for checkbox + if widget_type == TK_CHECKBUTTON: + field_var = tk.BooleanVar() + field_var.set(checkbox_to_bool(text)) + self.field = tk.Checkbutton(frame, variable=field_var) + expand = False else: - # didn't find a cell we can edit - self.active_edit = False + # create tk.StringVar for combo/entry + field_var = tk.StringVar() + field_var.set(text) + + # entry + if widget_type == TK_ENTRY: + self.field = ttk.Entry(frame, textvariable=field_var, justify="left") + expand = True + + if widget_type == TK_DATEPICKER: + text = dt.date.today() if type(text) is str else text + self.field = DatePicker( + frame, self.frm, init_date=text, textvariable=field_var + ) + expand = True + + # combobox + if widget_type == TK_COMBOBOX: + self.field = ttk.Combobox( + frame, textvariable=field_var, justify="left", state="readonly" + ) + self.field["values"] = combobox_values + self.field.bind("", self.combo_configure) + expand = True + + # bind text to Return (for save), and Escape (for discard) + # event is discarded + accept_dict = { + "data_key": data_key, + "table_element": table_element, + "row": row, + "column": column, + "col_idx": col_idx, + "combobox_values": combobox_values, + "widget_type": widget_type, + "field_var": field_var, + } + + self.field.bind( + "", + lambda event: self.accept(**accept_dict), + ) + self.field.bind("", lambda event: self.destroy()) + + if themepack.use_cell_buttons: + # buttons + self.accept_button = tk.Button( + frame, + text="\u2714", + foreground="green", + relief=tk.GROOVE, + command=lambda: self.accept(**accept_dict), + ) + self.cancel_button = tk.Button( + frame, + text="\u274E", + foreground="red", + relief=tk.GROOVE, + command=lambda: self.destroy(), + ) + # pack buttons + self.cancel_button.pack(side="right") + self.accept_button.pack(side="right") + + if widget_type == TK_DATEPICKER: + self.field.button.pack(side="right") + # have entry use remaining space + self.field.pack(side="left", expand=expand, fill="both") + + # select text and focus to begin with + if widget_type != TK_CHECKBUTTON: + self.field.select_range(0, tk.END) + self.field.focus_force() + + # bind single-clicks + self.destroy_bind = self.frm.window.TKroot.bind( + "", + lambda event: self.single_click_callback(event, accept_dict), + "+", + ) def accept( self, @@ -6018,7 +6057,7 @@ def accept( ) if cast_new_value is not Boolean.FALSE: # push row to dataset and update - dataset.set_current(column, cast_new_value) + dataset.set_current(column, cast_new_value, write_event=True) # Update matching field self.frm.update_fields(data_key, columns=[column]) # TODO: make sure we actually want to set new_value to cast @@ -6031,6 +6070,14 @@ def accept( if widget_type == TK_COMBOBOX: new_value = combobox_values[self.field.current()] + # if boolean, set + if widget_type == TK_CHECKBUTTON and themepack.display_boolean_as_checkbox: + new_value = ( + themepack.checkbox_true + if checkbox_to_bool(new_value) + else themepack.checkbox_false + ) + # update value row with new text values[col_idx] = new_value @@ -6050,6 +6097,7 @@ def accept( def destroy(self): # unbind self.frm.window.TKroot.unbind("", self.destroy_bind) + # destroy widets and window self.field.destroy() if themepack.use_cell_buttons: @@ -6072,16 +6120,20 @@ def single_click_callback( if region == "heading": self.destroy() return - # disregard if you click the field/buttons of celledit widget_list = [self.field] if themepack.use_cell_buttons: widget_list.append(self.accept_button) widget_list.append(self.cancel_button) - if event and event.widget in widget_list: + + # for datepicker + with contextlib.suppress(AttributeError): + widget_list.append(self.field.button) + if "ttkcalendar" in str(event.widget): return - # otherwise, accept + if event.widget in widget_list: + return self.accept(**accept_dict) def get_datakey_and_sgtable(self, treeview, frm): @@ -6091,11 +6143,7 @@ def get_datakey_and_sgtable(self, treeview, frm): ]: for e in frm[data_key].selector: element = e["element"] - if ( - element.widget == treeview - and "TableHeading" in element.metadata - and element.metadata["TableHeading"].edit_enable - ): + if element.widget == treeview and "TableHeading" in element.metadata: return data_key, element return None @@ -6117,6 +6165,183 @@ def combo_configure(self, event): combo.configure(style="SS.TCombobox") +class TtkCalendar(ttk.Frame): + """Internal Class.""" + + # Modified from Tkinter GUI Application Development Cookbook, MIT License. + + def __init__(self, master, init_date, textvariable, **kwargs): + # TODO, set these in themepack? + fwday = kwargs.pop("firstweekday", calendar.MONDAY) + sel_bg = kwargs.pop("selectbackground", "#ecffc4") + sel_fg = kwargs.pop("selectforeground", "#05640e") + + super().__init__(master, class_="TtkCalendar", **kwargs) + + self.master = master + self.cal_date = init_date + self.textvariable = textvariable + self.cal = calendar.TextCalendar(fwday) + self.font = tkfont.Font(self) + self.header = self.create_header() + self.table = self.create_table() + self.canvas = self.create_canvas(sel_bg, sel_fg) + self.build_calendar() + + def create_header(self): + left_arrow = {"children": [("Button.leftarrow", None)]} + right_arrow = {"children": [("Button.rightarrow", None)]} + style = ttk.Style(self) + style.layout("L.TButton", [("Button.focus", left_arrow)]) + style.layout("R.TButton", [("Button.focus", right_arrow)]) + + hframe = ttk.Frame(self) + btn_left = ttk.Button( + hframe, style="L.TButton", command=lambda: self.move_month(-1) + ) + btn_right = ttk.Button( + hframe, style="R.TButton", command=lambda: self.move_month(1) + ) + label = ttk.Label(hframe, width=15, anchor="center") + + hframe.pack(pady=5, anchor=tk.CENTER) + btn_left.grid(row=0, column=0) + label.grid(row=0, column=1, padx=12) + btn_right.grid(row=0, column=2) + return label + + def create_table(self): + cols = self.cal.formatweekheader(3).split() + table = ttk.Treeview(self, show="", selectmode="none", height=7, columns=cols) + table.bind("", self.minsize, "+") + table.pack(expand=1, fill=tk.BOTH) + table.tag_configure("header", background="grey90") + table.insert("", tk.END, values=cols, tag="header") + for _ in range(6): + table.insert("", tk.END) + + width = max(map(self.font.measure, cols)) + for col in cols: + table.column(col, width=width, minwidth=width, anchor=tk.E) + return table + + def create_canvas(self, bg, fg): + canvas = tk.Canvas( + self.table, background=bg, borderwidth=1, highlightthickness=0 + ) + canvas.text = canvas.create_text(0, 0, fill=fg, anchor=tk.W) + self.table.bind("", self.pressed_callback, "+") + return canvas + + def build_calendar(self): + year, month = self.cal_date.year, self.cal_date.month + month_name = self.cal.formatmonthname(year, month, 0) + month_weeks = self.cal.monthdayscalendar(year, month) + + self.header.config(text=month_name.title()) + items = self.table.get_children()[1:] + for week, item in itertools.zip_longest(month_weeks, items): + fmt_week = [f"{day:02d}" if day else "" for day in (week or [])] + self.table.item(item, values=fmt_week) + + def pressed_callback(self, event): + x, y, widget = event.x, event.y, event.widget + item = widget.identify_row(y) + column = widget.identify_column(x) + items = self.table.get_children()[1:] + + if not column or item not in items: + # clicked te header or outside the columns + return + + index = int(column[1]) - 1 + values = widget.item(item)["values"] + text = values[index] if len(values) else None + bbox = widget.bbox(item, column) + if bbox and text: + self.cal_date = dt.date(self.cal_date.year, self.cal_date.month, int(text)) + self.draw_selection(bbox) + self.textvariable.set(self.cal_date.strftime("%Y-%m-%d")) + + def draw_selection(self, bbox): + canvas, text = self.canvas, "%02d" % self.cal_date.day + x, y, width, height = bbox + textw = self.font.measure(text) + canvas.configure(width=width, height=height) + canvas.coords(canvas.text, width - textw, height / 2 - 1) + canvas.itemconfigure(canvas.text, text=text) + canvas.place(x=x, y=y) + + def set_date(self, dateobj): + self.cal_date = dateobj + self.canvas.place_forget() + self.build_calendar() + + def select_date(self): + bbox = self.get_bbox_for_date(self.cal_date) + if bbox: + self.draw_selection(bbox) + + def get_bbox_for_date(self, new_date): + items = self.table.get_children()[1:] + for item in items: + values = self.table.item(item)["values"] + for i, value in enumerate(values): + if isinstance(value, int) and value == new_date.day: + column = "#{}".format(i + 1) + self.table.update() + return self.table.bbox(item, column) + return None + + def move_month(self, offset): + self.canvas.place_forget() + month = self.cal_date.month - 1 + offset + year = self.cal_date.year + month // 12 + month = month % 12 + 1 + self.cal_date = dt.date(year, month, 1) + self.build_calendar() + + def minsize(self, e): + width, height = self.master.geometry().split("x") + height = height[: height.index("+")] + self.master.minsize(width, height) + + +class DatePicker(ttk.Entry): + def __init__(self, master, frm_reference, init_date, **kwargs): + self.frm = frm_reference + textvariable = kwargs["textvariable"] + self.calendar = TtkCalendar(self.frm.window.TKroot, init_date, textvariable) + self.calendar.place_forget() + self.button = ttk.Button(master, text="▼", width=2, command=self.show_calendar) + super().__init__(master, **kwargs) + + self.bind("", self.on_entry_key_release, "+") + self.calendar.bind("", self.hide_calendar, "+") + + def show_calendar(self, event=None): + self.configure(state="disabled") + self.calendar.place(in_=self, relx=0, rely=1) + self.calendar.focus_force() + self.calendar.select_date() + + def hide_calendar(self, event=None): + self.configure(state="!disabled") + self.calendar.place_forget() + self.focus_force() + + def on_entry_key_release(self, event=None): + # Check if the user has typed a valid date + try: + date_str = self.get() + date = dt.datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + return + + # Update the calendar to show the new date + self.calendar.set_date(date) + + class _LiveUpdate: """Internal class used to automatically sync selectors with field changes""" @@ -6175,7 +6400,7 @@ def sync(self, widget, widget_type): ) if new_value is not Boolean.FALSE: # push row to dataset and update - dataset.set_current(column, new_value) + dataset.set_current(column, new_value, write_event=True) # Update tableview if uses column: if dataset.column_likely_in_selector(column): @@ -6275,6 +6500,11 @@ class ThemePack: "text_min_width": 80, "combobox_min_width": 80, "checkbox_min_width": 75, + "datepicker_min_width": 80, + # Display boolean columns as checkboxes in sg.Tables + "display_boolean_as_checkbox": True, + "checkbox_true": "☑", + "checkbox_false": "☐", } """ Default Themepack. @@ -6454,7 +6684,7 @@ class LanguagePack: # ------------------------------------------------------------------------------ "quick_edit_title": "Quick Edit - {data_key}", # ------------------------------------------------------------------------------ - # Error when importing module for driver + # For Error when importing module for driver # ------------------------------------------------------------------------------ "import_module_failed_title": "Problem importing module", "import_module_failed": "Unable to import module neccessary for {name}\nException: {exception}\n\nTry `pip install {requires}`", # fmt: skip # noqa: E501 From 19a35b6e91bf3e213836b38c8c73d8f24ebc684a Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:36:37 -0400 Subject: [PATCH 13/66] Update checkbox_to_bool --- pysimplesql/pysimplesql.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 3eccf5b2..96df2aaa 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4367,7 +4367,16 @@ def checkbox_to_bool(value): :param value: Value to convert into True or False :returns: bool """ - return str(value).lower() in ["y", "yes", "t", "true", "1"] + return str(value).lower() in [ + "y", + "yes", + "t", + "true", + "1", + "on", + "enabled", + themepack.checkbox_true, + ] class Popup: From 97d2fea2b0d3149f6891c6207b8e1f9948e227d1 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:37:25 -0400 Subject: [PATCH 14/66] Add `add_placeholder_to` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This currently isn't integrated in any auto-logic, but user could do: # Add a placeholder to the search input ss.add_placeholder_to( win["Orders:search_input"], "🔍 Search...", ) I'll add a video for it. --- pysimplesql/pysimplesql.py | 113 +++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 96df2aaa..5112eafd 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4379,6 +4379,119 @@ def checkbox_to_bool(value): ] +class PlaceholderState(object): + # Author: Miguel Martinez Lopez + __slots__ = ( + "normal_color", + "normal_font", + "placeholder_text", + "placeholder_color", + "placeholder_font", + "with_placeholder", + ) + + +def add_placeholder_to( + element, placeholder: str, color: str = "grey", font=None +) -> PlaceholderState: + """ + Add a placeholder to the given element. + + This function adds a placeholder to the given tkinter or PySimpleGUI element. + The placeholder text is displayed in the element when the element is empty and + unfocused. When the element is clicked or focused, the placeholder text disappears + and the element becomes blank. When the element loses focus and is still empty, the + placeholder text reappears. + + This function is based on the recipe by Miguel Martinez Lopez, licensed under MIT. + It has been updated to allow tk.Text elements in addition to tk.Entry elements, + and to work with PySimpleGUI elements. + + :param element: A tkinter or PySimpleGUI element to add the placeholder to. + :type element: tkinter.Entry or tkinter.Text or PySimpleGUI.Input or + PySimpleGUI.Multiline + :param placeholder: The text to display as the placeholder. + :param color: The color of the placeholder text, default is 'grey'. + :type color: str + :param font: The font of the placeholder text, default is the same font as the + element. + :type font: str or None + :return: The PlaceholderState object that tracks the state of the placeholder. + :rtype: PlaceholderState + :raises ValueError: If the widget type is not supported. + + """ + if isinstance(element, (sg.Input, sg.Multiline)): + widget = element.Widget + else: + widget = element + + normal_color = widget.cget("fg") + normal_font = widget.cget("font") + + if font is None: + font = normal_font + + state = PlaceholderState() + state.normal_color = normal_color + state.normal_font = normal_font + state.placeholder_color = color + state.placeholder_font = font + state.placeholder_text = placeholder + state.with_placeholder = True + + if isinstance(widget, tk.Entry): + + def on_focusin(event, widget=widget, state=state): + if state.with_placeholder: + widget.delete(0, "end") + widget.config(fg=state.normal_color, font=state.normal_font) + + state.with_placeholder = False + + def on_focusout(event, widget=widget, state=state): + if not widget.get(): + widget.insert(0, state.placeholder_text) + widget.config(fg=state.placeholder_color, font=state.placeholder_font) + + state.with_placeholder = True + + widget.insert(0, placeholder) + widget.config(fg=color, font=font) + + widget.bind("", on_focusin, "+") + widget.bind("", on_focusout, "+") + + elif isinstance(widget, tk.Text): + + def on_focusin(event, widget=widget, state=state): + if state.with_placeholder: + widget.delete("1.0", "end") + widget.config(fg=state.normal_color, font=state.normal_font) + + state.with_placeholder = False + + def on_focusout(event, widget=widget, state=state): + if not widget.get("1.0", "end-1c"): + widget.insert("1.0", state.placeholder_text) + widget.config(fg=state.placeholder_color, font=state.placeholder_font) + + state.with_placeholder = True + + widget.insert("1.0", placeholder) + widget.config(fg=color, font=font) + + widget.bind("", on_focusin, "+") + widget.bind("", on_focusout, "+") + + else: + raise ValueError("Widget type not supported") + + widget.PlaceholderState = state + + return state + + class Popup: """ From 7a3d17b934d739118fb074f0f572d1e30651117d Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:37:48 -0400 Subject: [PATCH 15/66] Move LangFormat to be more logically placed (under LanguagePack --- pysimplesql/pysimplesql.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 5112eafd..25f04957 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4897,19 +4897,6 @@ def _animated_message(self, phrases: list, phrase_delay: float): return current_message -class LangFormat(dict): - - """ - This is a convenience class used by LanguagePack format_map calls, allowing users to - not include expected variables. - - Note: This is typically not used by the end user. - """ - - def __missing__(self, key): - return None - - class KeyGen: """ @@ -6843,6 +6830,19 @@ def __call__(self, lp_dict={}): lang = LanguagePack() +class LangFormat(dict): + + """ + This is a convenience class used by LanguagePack format_map calls, allowing users to + not include expected variables. + + Note: This is typically not used by the end user. + """ + + def __missing__(self, key): + return None + + # ====================================================================================== # ABSTRACTION LAYERS # ====================================================================================== From ca8f88a00c111456cc121fde8cbea9dea6acc816 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:39:58 -0400 Subject: [PATCH 16/66] New examples, orders & checkbox_behavior --- examples/SQLite_examples/checkbox_behavior.py | 80 ++++ examples/SQLite_examples/orders.py | 372 ++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 examples/SQLite_examples/checkbox_behavior.py create mode 100644 examples/SQLite_examples/orders.py diff --git a/examples/SQLite_examples/checkbox_behavior.py b/examples/SQLite_examples/checkbox_behavior.py new file mode 100644 index 00000000..662785de --- /dev/null +++ b/examples/SQLite_examples/checkbox_behavior.py @@ -0,0 +1,80 @@ +# fmt: off +import logging +import PySimpleGUI as sg + +sg.change_look_and_feel("SystemDefaultForReal") +sg.set_options(font=("Roboto", 11)) # Set the font and font size for the table + +import pysimplesql as ss # noqa: E402 + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) # <=== Set the logging level here (NOTSET,DEBUG,INFO,WARNING,ERROR,CRITICAL) + +sql = """ +CREATE TABLE checkboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bool_none BOOLEAN, + bool_true BOOLEAN Default True, + bool_false BOOLEAN Default False, + int_none INTEGER, + int_true INTEGER Default 1, + int_false INTEGER Default 0, + text_none TEXT, + text_true TEXT Default "True", + text_false TEXT Default "False" +); + +INSERT INTO checkboxes (bool_none, bool_true, bool_false, int_none, int_true, int_false, text_none, text_true, text_false) +VALUES (NULL,True,False,NULL,1,0,NULL,"True","False"); +""" + +# ------------------------- +# CREATE PYSIMPLEGUI LAYOUT +# ------------------------- +# Create a table heading object +headings = ss.TableHeadings(sort_enable=True, edit_enable=True) + +# Add columns to the table heading +headings.add_column('id', 'id', width=5) + +columns = ['bool_none', 'bool_true', 'bool_false', 'int_none', 'int_true', 'int_false', 'text_none', 'text_true', 'text_false'] + +for col in columns: + headings.add_column(col, col, width=8) + +fields = [] +for col in columns: + fields.append([ss.field(f'checkboxes.{col}', sg.Checkbox, size=(20, 10), label={col})]) + +layout = [ + [sg.Text('This test shows pysimplesql checkbox behavior.')], + [sg.Text('Each column is labeled as type: bool=BOOLEAN, int=INTEGER, text=TEXT')], + [sg.Text("And the DEFAULT set for new records, no default set, True,1,'True', or False,0,'False'")], + [ss.selector('checkboxes', sg.Table, num_rows=10, headings=headings, row_height=25)], + [ss.actions('checkboxes', edit_protect=False)], + fields, +] + +win = sg.Window('Checkbox Test', layout, finalize=True) +driver = ss.Driver.sqlite(":memory:", sql_commands=sql) +# Here is the magic! +frm = ss.Form( + driver, + bind_window=win, + live_update=True # this updates the `Selector`, sg.Table as we type in fields! + ) + +# --------- +# MAIN LOOP +# --------- +while True: + event, values = win.read() + + if event == sg.WIN_CLOSED or event == 'Exit': + frm.close() # <= ensures proper closing of the sqlite database and runs a database optimization + win.close() + break + elif ss.process_events(event, values): # <=== let PySimpleSQL process its own events! Simple! + logger.info(f'PySimpleDB event handler handled the event {event}!') + else: + logger.info(f'This event ({event}) is not yet handled.') diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py new file mode 100644 index 00000000..5211cb6b --- /dev/null +++ b/examples/SQLite_examples/orders.py @@ -0,0 +1,372 @@ +import logging + +import PySimpleGUI as sg +import pysimplesql as ss + +# custom code in the PySimpleGUI 'Main Loop' that saves when adding a new row +# to OrderDetails. +automatically_save_orderdetails = True + +sg.change_look_and_feel("SystemDefaultForReal") +sg.set_options(font=("Roboto", 11)) # Set the font and font size for the table +font = ("Roboto", 16) # To be used later to sg.Text headings + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +custom = { + "ttk_theme": "xpnative", + "default_label_size": (15, 1), + "default_element_size": (20, 1), + "default_mline_size": (30, 7), + # "marker_unsaved": "✔", +} +custom = custom | ss.tp_crystal_remix +ss.themepack(custom) + + +sql = """ +CREATE TABLE IF NOT EXISTS Customers ( + "CustomerID" INTEGER NOT NULL, + "Name" TEXT NOT NULL, + "Email" TEXT, + PRIMARY KEY("CustomerID" AUTOINCREMENT) +); + +CREATE TABLE IF NOT EXISTS Orders ( + "OrderID" INTEGER NOT NULL, + "CustomerID" INTEGER NOT NULL, + "OrderDate" DATE NOT NULL DEFAULT (date('now')), + "Total" REAL, + "Completed" BOOLEAN NOT NULL, + FOREIGN KEY ("CustomerID") REFERENCES Customers(CustomerID), + PRIMARY KEY("OrderID" AUTOINCREMENT) +); + +CREATE TABLE IF NOT EXISTS Products ( + "ProductID" INTEGER NOT NULL, + "Name" TEXT NOT NULL DEFAULT "New Product", + "Price" REAL NOT NULL, + "Inventory" INTEGER DEFAULT 0, + PRIMARY KEY("ProductID" AUTOINCREMENT) +); + +CREATE TABLE IF NOT EXISTS OrderDetails ( + "OrderDetailID" INTEGER NOT NULL, + "OrderID" INTEGER, + "ProductID" INTEGER NOT NULL, + "Quantity" INTEGER, + "Price" REAL, + "SubTotal" REAL GENERATED ALWAYS AS ("Price" * "Quantity") STORED, + FOREIGN KEY ("OrderID") REFERENCES "Orders"("OrderID") ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY ("ProductID") REFERENCES "Products"("ProductID"), + PRIMARY KEY("OrderDetailID" AUTOINCREMENT) +); + +-- Create a compound index on OrderID and ProductID columns in OrderDetails table +CREATE INDEX idx_orderdetails_orderid_productid ON OrderDetails (OrderID, ProductID); + +-- Trigger to set the price value for a new OrderDetail +CREATE TRIGGER IF NOT EXISTS set_price +AFTER INSERT ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE OrderDetails + SET Price = Products.Price + FROM Products + WHERE Products.ProductID = NEW.ProductID + AND OrderDetails.OrderDetailID = NEW.OrderDetailID; +END; + +-- Trigger to update the price value for an existing OrderDetail +CREATE TRIGGER IF NOT EXISTS set_price_update +AFTER UPDATE ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE OrderDetails + SET Price = Products.Price + FROM Products + WHERE Products.ProductID = NEW.ProductID + AND OrderDetails.OrderDetailID = NEW.OrderDetailID; +END; + +-- Trigger to set the total value for a new OrderDetail +CREATE TRIGGER IF NOT EXISTS set_total +AFTER INSERT ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE Orders + SET Total = ( + SELECT SUM(SubTotal) FROM OrderDetails WHERE OrderID = NEW.OrderID + ) + WHERE OrderID = NEW.OrderID; +END; + +-- Trigger to update the total value for an existing OrderDetail +CREATE TRIGGER IF NOT EXISTS update_total +AFTER UPDATE ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE Orders + SET Total = ( + SELECT SUM(SubTotal) FROM OrderDetails WHERE OrderID = NEW.OrderID + ) + WHERE OrderID = NEW.OrderID; +END; + +-- Trigger to update the total value for an existing OrderDetail +CREATE TRIGGER IF NOT EXISTS delete_order_detail +AFTER DELETE ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE Orders + SET Total = ( + SELECT SUM(SubTotal) FROM OrderDetails WHERE OrderID = OLD.OrderID + ) + WHERE OrderID = OLD.OrderID; +END; + +CREATE TRIGGER IF NOT EXISTS update_product_price +AFTER UPDATE ON Products +FOR EACH ROW +BEGIN + UPDATE OrderDetails + SET Price = NEW.Price + WHERE ProductID = NEW.ProductID; +END; + +INSERT INTO Customers (Name, Email) VALUES + ('Alice Rodriguez', 'alice.rodriguez@example.com'), + ('Bryan Patel', 'bryan.patel@example.com'), + ('Cassandra Kim', 'cassandra.kim@example.com'), + ('David Nguyen', 'david.nguyen@example.com'), + ('Ella Singh', 'ella.singh@example.com'), + ('Franklin Gomez', 'franklin.gomez@example.com'), + ('Gabriela Ortiz', 'gabriela.ortiz@example.com'), + ('Henry Chen', 'henry.chen@example.com'), + ('Isabella Kumar', 'isabella.kumar@example.com'), + ('Jonathan Lee', 'jonathan.lee@example.com'), + ('Katherine Wright', 'katherine.wright@example.com'), + ('Liam Davis', 'liam.davis@example.com'), + ('Mia Ali', 'mia.ali@example.com'), + ('Nathan Kim', 'nathan.kim@example.com'), + ('Oliver Brown', 'oliver.brown@example.com'), + ('Penelope Martinez', 'penelope.martinez@example.com'), + ('Quentin Carter', 'quentin.carter@example.com'), + ('Rosa Hernandez', 'rosa.hernandez@example.com'), + ('Samantha Jones', 'samantha.jones@example.com'), + ('Thomas Smith', 'thomas.smith@example.com'), + ('Uma Garcia', 'uma.garcia@example.com'), + ('Valentina Lopez', 'valentina.lopez@example.com'), + ('William Park', 'william.park@example.com'), + ('Xander Williams', 'xander.williams@example.com'), + ('Yara Hassan', 'yara.hassan@example.com'), + ('Zoe Perez', 'zoe.perez@example.com'); + +INSERT INTO Products (Name, Price, Inventory) VALUES + ('Thingamabob', 5.00, 200), + ('Doohickey', 15.00, 75), + ('Whatchamacallit', 25.00, 50), + ('Gizmo', 10.00, 100), + ('Widget', 20.00, 60), + ('Doodad', 30.00, 40), + ('Sprocket', 7.50, 150), + ('Flibbertigibbet', 12.50, 90), + ('Thingamajig', 22.50, 30), + ('Dooberry', 17.50, 50), + ('Whirligig', 27.50, 25), + ('Gadget', 8.00, 120), + ('Contraption', 18.00, 65), + ('Thingummy', 28.00, 35), + ('Dinglehopper', 9.50, 100), + ('Doodlywhatsit', 19.50, 55), + ('Whatnot', 29.50, 20), + ('Squiggly', 6.50, 175), + ('Fluffernutter', 11.50, 80), + ('Goober', 21.50, 40), + ('Doozie', 16.50, 60), + ('Whammy', 26.50, 30), + ('Thingy', 7.00, 130), + ('Doodadery', 17.00, 70); + +INSERT INTO Orders (CustomerID, OrderDate, Completed) +SELECT CustomerID, DATE('now', '-' || (ABS(RANDOM()) % 30) || ' days'), False +FROM Customers +ORDER BY RANDOM() LIMIT 100; + +INSERT INTO OrderDetails (OrderID, ProductID, Quantity) +SELECT O.OrderID, P.ProductID, (ABS(RANDOM()) % 10) + 1 +FROM Orders O +JOIN (SELECT ProductID FROM Products ORDER BY RANDOM() LIMIT 25) P +ON 1=1 +ORDER BY 1; +""" + +# ------------------------- +# CREATE PYSIMPLEGUI LAYOUT +# ------------------------- + + +# Create a basic menu +menu_def = [ + [ + "&File", + [ + "&Save", + "&Requery All", + ], + ], + ["&Edit", ["&Edit Products", "&Edit Customers"]], +] +layout = [[sg.Menu(menu_def, key="-MENUBAR-", font="_ 12")]] + +# Define the columns for the table selector using the TableHeading convenience class. +order_heading = ss.TableHeadings( + sort_enable=True, # Click a header to sort + edit_enable=True, # Double-click a cell to make edits + save_enable=True, # Double-click 💾 in sg.Table to save row +) + +# Add columns +order_heading.add_column(column="OrderID", heading_column="ID", width=5) +order_heading.add_column("CustomerID", "Customer", 30) +order_heading.add_column("OrderDate", "Date", 20) +order_heading.add_column( + "Total", "Total", width=10, readonly=True +) # set to True to disable editing for individual columns!) +order_heading.add_column("Completed", "✔", 8) + +# Layout +layout.append( + [ + [sg.Text("Orders", font=font)], + [ + ss.selector( + "Orders", + sg.Table, + num_rows=5, + headings=order_heading, + row_height=25, + ) + ], + [ss.actions("Orders")], + [sg.HorizontalSeparator()], + ] +) + +# OrderDetails TableHeadings: +details_heading = ss.TableHeadings(sort_enable=True, edit_enable=True, save_enable=True) +details_heading.add_column("ProductID", "Product", 30) +details_heading.add_column("Quantity", "Quantity", 10) +details_heading.add_column("Price", "Price/Ea", 10, readonly=True) +details_heading.add_column("SubTotal", "SubTotal", 10) + +layout.append( + [ + [sg.Text("Order Details", font=font)], + [ss.field("Orders.CustomerID", sg.Combo, label="Customer")], + [ + ss.field("Orders.OrderDate", label="Date"), + ], + [ss.field("Orders.Completed", sg.Checkbox, default=False)], + [ + ss.selector( + "OrderDetails", + sg.Table, + num_rows=10, + headings=details_heading, + row_height=25, + ) + ], + [ + ss.actions( + "OrderDetails", default=False, save=True, insert=True, delete=True + ) + ], + [ss.field("OrderDetails.ProductID", sg.Combo)], + [ss.field("OrderDetails.Quantity")], + [ss.field("OrderDetails.Price", sg.Text)], + [ss.field("OrderDetails.SubTotal", sg.Text)], + ] +) + +win = sg.Window( + "Order Example", + layout, + finalize=True, + ttk_theme="xpnative", + icon=ss.themepack.icon, +) + +# Expand our sg.Tables so they fill the screen +win["Orders:selector"].expand(True, True) +win["Orders:selector"].table_frame.pack(expand=True, fill="both") +win["OrderDetails:selector"].expand(True, True) +win["OrderDetails:selector"].table_frame.pack(expand=True, fill="both") + +# Create sqlite driver, keeping the database in memory +driver = ss.Driver.sqlite(":memory:", sql_commands=sql) +frm = ss.Form( + driver, + bind_window=win, + live_update=True, # this updates the `Selector`, sg.Table as we type in fields! +) + +frm.edit_protect() # Comment this out to edit protect when the window is created. +# Reverse the default sort order so Orders are sorted by date +frm["Orders"].set_order_clause("ORDER BY OrderDate ASC") +# Requery the data since we made changes to the sort order +frm["Orders"].requery() +# Set the column order for search operations. +frm["Orders"].set_search_order(["CustomerID"]) + +# Add a placeholder to the search input +ss.add_placeholder_to( + win["Orders:search_input"], + "🔍 Search...", +) + +# --------- +# MAIN LOOP +# --------- +while True: + event, values = win.read() + if event == sg.WIN_CLOSED or event == "Exit": + frm.close() # <= ensures proper closing of the sqlite database and runs a database optimization + win.close() + break + # <=== let PySimpleSQL process its own events! Simple! + elif ss.process_events(event, values): + logger.info(f"PySimpleDB event handler handled the event {event}!") + # Code to automatically save and refresh OrderDetails: + elif ( + "current_row_updated" in event + and values["current_row_updated"]["data_key"] == "OrderDetails" + and automatically_save_orderdetails + ): + dataset = frm["OrderDetails"] + current_row = dataset.get_current_row() + # after a product and quantity is entered, save and requery + if dataset.row_count and current_row["ProductID"] and current_row["Quantity"]: + row_is_virtual = dataset.row_is_virtual() + dataset.save_record(display_message=False) + frm["Orders"].requery(select_first=False) + frm.update_selectors("Orders") + if not row_is_virtual: + dataset.requery(select_first=False) + frm.update_elements("OrderDetails") + # logic to display the quick_editor for products and customers + elif "Edit Products" in event: + frm["Products"].quick_editor() + elif "Edit Customers" in event: + frm["Customers"].quick_editor() + # call a Form-level save + elif "Save" in event: + # frm.save_records() + frm.set_live_update(False) + # call a Form-level requery + elif "Requery All" in event: + # frm.requery_all() + frm.set_live_update(True) + else: + pass From 2e1e8147b749fc9115baaeaa2a86a5ea627b3f9e Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 16:45:15 -0400 Subject: [PATCH 17/66] Black fix --- pysimplesql/pysimplesql.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 25f04957..00457fa7 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1593,7 +1593,8 @@ def set_current( "value": value, }, ) - # # TODO: I'd like to talk about extending callbacks to include + + # # TODO: I'd like to talk about extending callbacks to include # # data_key (if callback is for a specific data_key) # if "current_row_updated" in dataset.callbacks: # dataset.callbacks["current_row_updated"]( From e72fced28de0dc0ec3757de15fbb61d714bc4dbb Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 20:39:40 -0400 Subject: [PATCH 18/66] Keep original info popup open, if user double-clicks save (for instance) --- pysimplesql/pysimplesql.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 00457fa7..9d907325 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4611,7 +4611,7 @@ def info( msg_lines = msg.splitlines() layout = [[sg.Text(line, font="bold")] for line in msg_lines] if self.popup_info: - self.popup_info.close() + return self.popup_info = sg.Window( title=title, layout=layout, @@ -4631,6 +4631,7 @@ def _auto_close(self): """ if self.popup_info: self.popup_info.close() + self.popup_info = None class ProgressBar: From 8f442a0c01f7ffad27eacd959ddeb4c2288a3498 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 20:53:15 -0400 Subject: [PATCH 19/66] All sg.Windows use icon=themepack.icon --- pysimplesql/pysimplesql.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 9d907325..11bf7885 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2172,6 +2172,7 @@ def duplicate_record( keep_on_top=True, modal=True, ttk_theme=themepack.ttk_theme, + icon=themepack.icon, ).read(close=True) if answer[0] == "parent": children = False @@ -2542,6 +2543,7 @@ def quick_editor( modal=True, finalize=True, ttk_theme=themepack.ttk_theme, # Must, otherwise will redraw window + icon=themepack.icon, ) quick_frm = Form(self.frm.driver, bind_window=quick_win) @@ -4536,6 +4538,7 @@ def ok(self, title, msg): ttk_theme=themepack.ttk_theme, element_justification="center", enable_close_attempted_event=True, + icon=themepack.icon, ) while True: @@ -4577,6 +4580,7 @@ def yes_no(self, title, msg): ttk_theme=themepack.ttk_theme, element_justification="center", enable_close_attempted_event=True, + icon=themepack.icon, ) while True: @@ -4622,6 +4626,7 @@ def info( element_justification="center", ttk_theme=themepack.ttk_theme, enable_close_attempted_event=True, + icon=themepack.icon, ) self.window.TKroot.after(int(auto_close_seconds * 1000), self._auto_close) @@ -4703,6 +4708,7 @@ def _create_window(self): keep_on_top=True, finalize=True, ttk_theme=themepack.ttk_theme, + icon=themepack.icon, ) @@ -4819,6 +4825,7 @@ async def _gui(self): keep_on_top=True, finalize=True, ttk_theme=themepack.ttk_theme, + icon=themepack.icon, ) current_count = 0 From 64f256650b846c6fc74cd63a889ec05591bf37d7 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 20:57:05 -0400 Subject: [PATCH 20/66] Don't prompt_save after search --- pysimplesql/pysimplesql.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 11bf7885..85cb68cf 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1426,6 +1426,7 @@ def process_row(row): pk=pk, update_elements=update_elements, requery_dependents=requery_dependents, + skip_prompt_save=True, ) # callback From 77199063940f0204d4e9f7b2cb7515c5d6deb353 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 21:06:50 -0400 Subject: [PATCH 21/66] Add popup to failed search --- examples/SQLite_examples/orders.py | 4 ++-- pysimplesql/pysimplesql.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index 5211cb6b..646ae65e 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -40,7 +40,7 @@ "Total" REAL, "Completed" BOOLEAN NOT NULL, FOREIGN KEY ("CustomerID") REFERENCES Customers(CustomerID), - PRIMARY KEY("OrderID" AUTOINCREMENT) + PRIMARY KEY("OrderID" AUTOINCREMENT) ); CREATE TABLE IF NOT EXISTS Products ( @@ -60,7 +60,7 @@ "SubTotal" REAL GENERATED ALWAYS AS ("Price" * "Quantity") STORED, FOREIGN KEY ("OrderID") REFERENCES "Orders"("OrderID") ON UPDATE CASCADE ON DELETE CASCADE, FOREIGN KEY ("ProductID") REFERENCES "Products"("ProductID"), - PRIMARY KEY("OrderDetailID" AUTOINCREMENT) + PRIMARY KEY("OrderDetailID" AUTOINCREMENT) ); -- Create a compound index on OrderID and ProductID columns in OrderDetails table diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 85cb68cf..0f6f8b38 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1346,6 +1346,7 @@ def search( update_elements: bool = True, requery_dependents: bool = True, skip_prompt_save: bool = False, + display_message: bool = None, ) -> Union[SEARCH_FAILED, SEARCH_RETURNED, SEARCH_ABORTED]: """ Move to the next record in the `DataSet` that contains `search_string`. @@ -1364,6 +1365,8 @@ def search( records. :param requery_dependents: (optional) Requery dependents after switching records :param skip_prompt_save: (optional) True to skip prompting to save dirty records + :param display_message: Displays a message "Search Failed: ...", otherwise is + silent on success. :returns: One of the following search values: `SEARCH_FAILED`, `SEARCH_RETURNED`, `SEARCH_ABORTED`. """ @@ -1443,9 +1446,14 @@ def process_row(row): self.callbacks["record_changed"](self.frm, self.frm.window) return SEARCH_RETURNED + self.frm.popup.ok( + lang.dataset_search_failed_title, + lang.dataset_search_failed.format_map( + LangFormat(search_string=search_string) + ), + ) return SEARCH_FAILED # If we have made it here, then it was not found! - # sg.Popup('Search term "'+str+'" not found!') # TODO: Play sound? def set_by_index( @@ -6773,6 +6781,9 @@ class LanguagePack: "dataset_save_keyed_fail": "Query failed: {exception}.", "dataset_save_fail_title": "Problem Saving", "dataset_save_fail": "Query failed: {exception}.", + # DataSet search + "dataset_search_failed_title": "Search Failed", + "dataset_search_failed": "Failed to find:\n{search_string}", # ------------------------------------------------------------------------------ # Delete # ------------------------------------------------------------------------------ From af34216d1dda753e4381a7d004d7e9a55a7db19e Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 22:10:54 -0400 Subject: [PATCH 22/66] Clean up orders.py example a bit --- examples/SQLite_examples/orders.py | 56 ++++++++++++++---------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index 646ae65e..4c6979ff 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -8,7 +8,10 @@ automatically_save_orderdetails = True sg.change_look_and_feel("SystemDefaultForReal") -sg.set_options(font=("Roboto", 11)) # Set the font and font size for the table + +# Set a better looking font, and standard font-size. +# Enable dpi_awareness to reduce fuzziness on Windows +sg.set_options(font=("Arial", 11), dpi_awareness=True) font = ("Roboto", 16) # To be used later to sg.Text headings logger = logging.getLogger(__name__) @@ -250,7 +253,7 @@ ) ], [ss.actions("Orders")], - [sg.HorizontalSeparator()], + [sg.Sizer(h_pixels=0, v_pixels=20)], ] ) @@ -261,34 +264,29 @@ details_heading.add_column("Price", "Price/Ea", 10, readonly=True) details_heading.add_column("SubTotal", "SubTotal", 10) -layout.append( +orderdetails_layout = [ + [ss.field("Orders.CustomerID", sg.Combo, label="Customer")], [ - [sg.Text("Order Details", font=font)], - [ss.field("Orders.CustomerID", sg.Combo, label="Customer")], - [ - ss.field("Orders.OrderDate", label="Date"), - ], - [ss.field("Orders.Completed", sg.Checkbox, default=False)], - [ - ss.selector( - "OrderDetails", - sg.Table, - num_rows=10, - headings=details_heading, - row_height=25, - ) - ], - [ - ss.actions( - "OrderDetails", default=False, save=True, insert=True, delete=True - ) - ], - [ss.field("OrderDetails.ProductID", sg.Combo)], - [ss.field("OrderDetails.Quantity")], - [ss.field("OrderDetails.Price", sg.Text)], - [ss.field("OrderDetails.SubTotal", sg.Text)], - ] -) + ss.field("Orders.OrderDate", label="Date"), + ], + [ss.field("Orders.Completed", sg.Checkbox, default=False)], + [ + ss.selector( + "OrderDetails", + sg.Table, + num_rows=10, + headings=details_heading, + row_height=25, + ) + ], + [ss.actions("OrderDetails", default=False, save=True, insert=True, delete=True)], + [ss.field("OrderDetails.ProductID", sg.Combo)], + [ss.field("OrderDetails.Quantity")], + [ss.field("OrderDetails.Price", sg.Text)], + [ss.field("OrderDetails.SubTotal", sg.Text)], +] + +layout.append([sg.Frame("Order Details", orderdetails_layout, expand_x=True)]) win = sg.Window( "Order Example", From 466d39f87917f6971f79952a6068c6fb61c26268 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 16 May 2023 23:03:01 -0400 Subject: [PATCH 23/66] Cleanup orders.py --- examples/SQLite_examples/orders.py | 73 ++++++++++++++++-------------- pysimplesql/pysimplesql.py | 2 +- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index 4c6979ff..5faa3d55 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -3,30 +3,28 @@ import PySimpleGUI as sg import pysimplesql as ss -# custom code in the PySimpleGUI 'Main Loop' that saves when adding a new row -# to OrderDetails. -automatically_save_orderdetails = True - +# PySimpleGUI options +# ----------------------------- sg.change_look_and_feel("SystemDefaultForReal") - -# Set a better looking font, and standard font-size. -# Enable dpi_awareness to reduce fuzziness on Windows sg.set_options(font=("Arial", 11), dpi_awareness=True) -font = ("Roboto", 16) # To be used later to sg.Text headings +# Setup Logger +# ----------------------------- logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) +# Use the `xpnative` ttk_theme, and the `crystal_remix` iconset +# ----------------------------- custom = { "ttk_theme": "xpnative", - "default_label_size": (15, 1), - "default_element_size": (20, 1), - "default_mline_size": (30, 7), - # "marker_unsaved": "✔", } custom = custom | ss.tp_crystal_remix ss.themepack(custom) +# SQL Statement +# ====================================================================================== +# While this example uses triggers to calculate prices and sub/totals, they are not +# required for pysimplesql to operate. See simpler examples, like journal. sql = """ CREATE TABLE IF NOT EXISTS Customers ( @@ -209,25 +207,22 @@ # CREATE PYSIMPLEGUI LAYOUT # ------------------------- - +# fmt: off # Create a basic menu menu_def = [ - [ - "&File", - [ - "&Save", - "&Requery All", - ], - ], + ["&File",["&Save","&Requery All",],], ["&Edit", ["&Edit Products", "&Edit Customers"]], ] +# fmt: on layout = [[sg.Menu(menu_def, key="-MENUBAR-", font="_ 12")]] -# Define the columns for the table selector using the TableHeading convenience class. +# Define the columns for the table selector using the TableHeading class. order_heading = ss.TableHeadings( - sort_enable=True, # Click a header to sort - edit_enable=True, # Double-click a cell to make edits - save_enable=True, # Double-click 💾 in sg.Table to save row + sort_enable=True, # Click a heading to sort + edit_enable=True, # Double-click a cell to make edits. + # Click 💾 in sg.Table Heading to trigger DataSet.save_record() + # Exempted: Primary Key columns, Generated columns, and columns set as readonly + save_enable=True, ) # Add columns @@ -242,7 +237,7 @@ # Layout layout.append( [ - [sg.Text("Orders", font=font)], + [sg.Text("Orders", font="_16")], [ ss.selector( "Orders", @@ -292,6 +287,8 @@ "Order Example", layout, finalize=True, + # Below is Important! pysimplesql progressbars/popups/quick_editors use + # ttk_theme and icon as defined in themepack. ttk_theme="xpnative", icon=ss.themepack.icon, ) @@ -302,14 +299,20 @@ win["OrderDetails:selector"].expand(True, True) win["OrderDetails:selector"].table_frame.pack(expand=True, fill="both") +# Init pysimplesql Driver and Form +# -------------------------------- + # Create sqlite driver, keeping the database in memory driver = ss.Driver.sqlite(":memory:", sql_commands=sql) frm = ss.Form( driver, bind_window=win, - live_update=True, # this updates the `Selector`, sg.Table as we type in fields! + live_update=True, # this updates the `Selector`, sg.Table as we type in fields. ) +# Few more settings +# ----------------- + frm.edit_protect() # Comment this out to edit protect when the window is created. # Reverse the default sort order so Orders are sorted by date frm["Orders"].set_order_clause("ORDER BY OrderDate ASC") @@ -336,11 +339,12 @@ # <=== let PySimpleSQL process its own events! Simple! elif ss.process_events(event, values): logger.info(f"PySimpleDB event handler handled the event {event}!") + # Code to automatically save and refresh OrderDetails: + # ---------------------------------------------------- elif ( "current_row_updated" in event and values["current_row_updated"]["data_key"] == "OrderDetails" - and automatically_save_orderdetails ): dataset = frm["OrderDetails"] current_row = dataset.get_current_row() @@ -350,21 +354,22 @@ dataset.save_record(display_message=False) frm["Orders"].requery(select_first=False) frm.update_selectors("Orders") + # will need to requery if updating, rather than inserting a new record if not row_is_virtual: dataset.requery(select_first=False) frm.update_elements("OrderDetails") - # logic to display the quick_editor for products and customers + # ---------------------------------------------------- + + # Display the quick_editor for products and customers elif "Edit Products" in event: frm["Products"].quick_editor() elif "Edit Customers" in event: frm["Customers"].quick_editor() # call a Form-level save elif "Save" in event: - # frm.save_records() - frm.set_live_update(False) + frm.save_records() # call a Form-level requery elif "Requery All" in event: - # frm.requery_all() - frm.set_live_update(True) + frm.requery_all() else: - pass + logger.info(f"This event ({event}) is not yet handled.") diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 0f6f8b38..274c2fa1 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -6612,7 +6612,7 @@ class ThemePack: # Label Size # Sets the default label (text) size when `field()` is used. # A label is static text that is displayed near the element to describe it. - "default_label_size": (20, 1), # (width, height) + "default_label_size": (15, 1), # (width, height) # Element Size # Sets the default element size when `field()` is used. # The size= parameter of `field()` will override this. From 16533092c49979e947f599957ee80d640ee1c8b4 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 17 May 2023 13:06:54 -0400 Subject: [PATCH 24/66] convert: `is type(element) to isinstance() --- pysimplesql/pysimplesql.py | 80 ++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 47 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 274c2fa1..39502744 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -907,7 +907,7 @@ def records_changed(self, column: str = None, recursive=True) -> bool: continue # if sg.Text - if type(mapped.element) is sg.Text: + if isinstance(mapped.element, sg.Text): continue # don't check if there aren't any rows. Fixes checkbox = '' when no @@ -933,7 +933,7 @@ def records_changed(self, column: str = None, recursive=True) -> bool: mapped.column, table_val, element_val, - bool(type(mapped.element) is sg.PySimpleGUI.Checkbox), + bool(isinstance(mapped.element, sg.Checkbox)), ) if new_value is not Boolean.FALSE: dirty = True @@ -1669,12 +1669,7 @@ def add_selector( :param where_value: (optional) :returns: None """ - if type(element) not in [ - sg.PySimpleGUI.Listbox, - sg.PySimpleGUI.Slider, - sg.Combo, - sg.Table, - ]: + if not isinstance(element, (sg.Listbox, sg.Slider, sg.Combo, sg.Table)): raise RuntimeError( f"add_selector() error: {element} is not a supported element." ) @@ -1806,7 +1801,7 @@ def save_record( # Propagate GUI data back to the stored current_row for mapped in [m for m in self.frm.element_map if m.dataset == self]: # skip if sg.Text - if type(mapped.element) is sg.Text: + if isinstance(mapped.element, sg.Text): continue # convert the data into the correct type using the domain in ColumnInfo @@ -3268,7 +3263,7 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: element = win[key] # Skip this element if there is no metadata present - if type(element.metadata) is not dict: + if not isinstance(element.metadata, dict): continue # Process the filter to ensure this element should be mapped to this Form @@ -3342,7 +3337,10 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: ) # Enable sorting if TableHeading is present - if type(element) is sg.Table and "TableHeading" in element.metadata: + if ( + isinstance(element, sg.Table) + and "TableHeading" in element.metadata + ): table_heading: TableHeadings = element.metadata["TableHeading"] # We need a whole chain of things to happen # when a heading is clicked on: @@ -3435,7 +3433,7 @@ def auto_map_events(self, win: sg.Window) -> None: # key = str(key) # sometimes end up with an integer element 0?TODO:Research element = win[key] # Skip this element if there is no metadata present - if type(element.metadata) is not dict: + if not isinstance(element.metadata, dict): logger.debug(f"Skipping mapping of {key}") continue if element.metadata["Form"] != self: @@ -3862,13 +3860,14 @@ def update_fields( if mapped.element in omit_elements: continue - if combo_values_only and type(mapped.element) is not sg.PySimpleGUI.Combo: + if combo_values_only and not isinstance(mapped.element, sg.Combo): continue if len(columns) and mapped.column not in columns: continue - if type(mapped.element) is not sg.Text: # don't show markers for sg.Text + # don't show markers for sg.Text + if not isinstance(mapped.element, sg.Text): # Show the Required Record marker if the column has notnull set and # this is a virtual row marker_key = mapped.element.key + ":marker" @@ -3904,10 +3903,10 @@ def update_fields( mapped.column, mapped.where_column, mapped.where_value ) # TODO, may need to add more?? - if type(mapped.element) in [sg.PySimpleGUI.CBox]: + if isinstance(mapped.element, sg.Checkbox): updated_val = checkbox_to_bool(updated_val) - elif type(mapped.element) is sg.PySimpleGUI.Combo: + elif isinstance(mapped.element, sg.Combo): # Update elements with foreign dataset first # This will basically only be things like comboboxes # Find the relationship to determine which table to get data from @@ -3940,7 +3939,7 @@ def update_fields( # and update element mapped.element.update(values=combo_vals, readonly=True) - elif type(mapped.element) is sg.Text: + elif isinstance(mapped.element, sg.Text): rels = Relationship.get_relationships(mapped.dataset.table) found = False # try to get description of linked if foreign-key @@ -3955,7 +3954,7 @@ def update_fields( updated_val = mapped.dataset[mapped.column] mapped.element.update("") - elif type(mapped.element) is sg.PySimpleGUI.Table: + elif isinstance(mapped.element, sg.Table): # Tables use an array of arrays for values. Note that the headings # can't be changed. values = mapped.dataset.table_values() @@ -3977,10 +3976,7 @@ def update_fields( ) continue - elif type(mapped.element) in [ - sg.PySimpleGUI.InputText, - sg.PySimpleGUI.Multiline, - ]: + elif isinstance(mapped.element, (sg.Input, sg.Multiline)): # Update the element in the GUI # For text objects, lets clear it first... @@ -3989,10 +3985,10 @@ def update_fields( updated_val = mapped.dataset[mapped.column] - elif type(mapped.element) is sg.PySimpleGUI.Checkbox: + elif isinstance(mapped.element, sg.Checkbox): updated_val = checkbox_to_bool(mapped.dataset[mapped.column]) - elif type(mapped.element) is sg.PySimpleGUI.Image: + elif isinstance(mapped.element, sg.Image): val = mapped.dataset[mapped.column] try: @@ -4050,10 +4046,7 @@ def update_selectors( if element.key in self.callbacks: self.callbacks[element.key]() - if ( - type(element) == sg.PySimpleGUI.Listbox - or type(element) == sg.PySimpleGUI.Combo - ): + if isinstance(element, (sg.Listbox, sg.Combo)): logger.debug("update_elements: List/Combo selector found...") lst = [] for _, r in dataset.rows.iterrows(): @@ -4078,7 +4071,7 @@ def update_selectors( # set vertical scroll bar to follow selected element # (for listboxes only) - if type(element) == sg.PySimpleGUI.Listbox: + if isinstance(element, sg.Listbox): try: element.set_vscroll_position( dataset.current_index / len(lst) @@ -4086,12 +4079,12 @@ def update_selectors( except ZeroDivisionError: element.set_vscroll_position(0) - elif type(element) == sg.PySimpleGUI.Slider: + elif isinstance(element, sg.Slider): # Re-range the element depending on the number of records l = dataset.row_count # noqa: E741 element.update(value=dataset._current_index + 1, range=(1, l)) - elif type(element) is sg.PySimpleGUI.Table: + elif isinstance(element, sg.Table): logger.debug("update_elements: Table selector found...") # Populate entries try: @@ -4194,20 +4187,18 @@ def process_events(self, event: str, values: list) -> bool: element: sg.Element = e["element"] if element.key == event and len(dataset.rows) > 0: changed = False # assume that a change will not take place - if type(element) == sg.PySimpleGUI.Listbox: + if isinstance(element, sg.Listbox): row = values[element.Key][0] dataset.set_by_pk(row.get_pk()) changed = True - elif type(element) == sg.PySimpleGUI.Slider: + elif isinstance(element, sg.Slider): dataset.set_by_index(int(values[event]) - 1) changed = True - elif type(element) == sg.PySimpleGUI.Combo: + elif isinstance(element, sg.Combo): row = values[event] dataset.set_by_pk(row.get_pk()) changed = True - elif type(element) is sg.PySimpleGUI.Table and len( - values[event] - ): + elif isinstance(element, sg.Table) and len(values[event]): index = values[event][0] pk = self.window[event].Values[index].pk @@ -4235,12 +4226,7 @@ def update_element_states( if mapped.table != table: continue element = mapped.element - if type(element) in [ - sg.PySimpleGUI.Input, - sg.PySimpleGUI.MLine, - sg.PySimpleGUI.Combo, - sg.PySimpleGUI.Checkbox, - ]: + if isinstance(element, (sg.Input, sg.Multiline, sg.Combo, sg.Checkbox)): # if element.Key in self.window.key_dict.keys(): logger.debug( f"Updating element {element.Key} to disabled: " @@ -5120,7 +5106,7 @@ def field( else: first_param = "" - if element.__name__ == "Multiline": + if isinstance(element, sg.Multiline): layout_element = element( first_param, key=key, @@ -5707,7 +5693,7 @@ def selector( ) elif element == sg.Table: # Check if the headings arg is a Table heading... - if kwargs["headings"].__class__.__name__ == "TableHeadings": + if isinstance(kwargs["headings"], TableHeadings): # Overwrite the kwargs from the TableHeading info kwargs["visible_column_map"] = kwargs["headings"].visible_map() kwargs["col_widths"] = kwargs["headings"].width_map() @@ -5955,7 +5941,7 @@ def __init__(self, frm_reference: Form): def __call__(self, event): # if double click a treeview - if event.widget.__class__.__name__ == "Treeview": + if isinstance(event.widget, ttk.Treeview): tk_widget = event.widget # identify region region = tk_widget.identify("region", event.x, event.y) @@ -6240,7 +6226,7 @@ def single_click_callback( accept_dict, ): # destroy if you click a heading while editing - if event.widget.__class__.__name__ == "Treeview": + if isinstance(event.widget, ttk.Treeview): tk_widget = event.widget # identify region region = tk_widget.identify("region", event.x, event.y) From 037529e6102380515c1b5879f19376c74e3993f9 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 17 May 2023 13:20:48 -0400 Subject: [PATCH 25/66] Missed one isinstance replacement --- pysimplesql/pysimplesql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 39502744..7a124e03 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5153,7 +5153,7 @@ def field( ], pad=(0, 0), ) - if element.__name__ == "Text": # don't show markers for sg.Text + if isinstance(element, sg.Text): # don't show markers for sg.Text if no_label: layout = [[sg.Text(" "), layout_element]] elif label_above: From 2cd2be2c5856c212f4a8609d4ac592fcd625a297 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 17 May 2023 14:00:31 -0400 Subject: [PATCH 26/66] These arnt isinstance yet, they are just objects --- pysimplesql/pysimplesql.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 7a124e03..5504414a 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5106,7 +5106,7 @@ def field( else: first_param = "" - if isinstance(element, sg.Multiline): + if element == sg.Multiline: layout_element = element( first_param, key=key, @@ -5153,7 +5153,7 @@ def field( ], pad=(0, 0), ) - if isinstance(element, sg.Text): # don't show markers for sg.Text + if element == sg.Text: # don't show markers for sg.Text if no_label: layout = [[sg.Text(" "), layout_element]] elif label_above: @@ -7189,7 +7189,8 @@ def default_row_dict(self, dataset: DataSet) -> dict: else: default = null_default # string-like, not description_column - default = "" + else: + default = "" else: # Load the default that was fetched from the database # during ColumnInfo creation From 956049b1a1bfabe20e3e6bed2fb5fada25f95c6f Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 17 May 2023 14:20:55 -0400 Subject: [PATCH 27/66] Fix pd.Series converting int to floats, Fix get_current returning None for 0 or 0.0 --- pysimplesql/pysimplesql.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 5504414a..97547b88 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1568,7 +1568,7 @@ def get_current( """ logger.debug(f"Getting current record for {self.table}.{column}") if self.row_count: - if self.get_current_row()[column]: + if self.get_current_row()[column] is not None: return self.get_current_row()[column] return default return default @@ -2772,7 +2772,9 @@ def insert_row(self, row: dict, idx: int = None) -> None: :param idx: The index where the row should be inserted (default to last index) :returns: None """ - row_series = pd.Series(row) + row_series = pd.Series(row, dtype=object) + # Infer better data types for the Series + # row_series = row_series.infer_objects() if self.rows.empty: self.rows = Result.set( pd.concat([self.rows, row_series.to_frame().T], ignore_index=True) @@ -3866,8 +3868,9 @@ def update_fields( if len(columns) and mapped.column not in columns: continue - # don't show markers for sg.Text - if not isinstance(mapped.element, sg.Text): + # Update Markers + # -------------------------------------------------------------------------- + if not isinstance(mapped.element, sg.Text): # not for sg.Text # Show the Required Record marker if the column has notnull set and # this is a virtual row marker_key = mapped.element.key + ":marker" From c56233b9a9db69db22213f94ac4f0d89ba4a2401 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 00:09:27 -0400 Subject: [PATCH 28/66] =?UTF-8?q?Add=20basic=20*Required=20to=20sg.Inputs,?= =?UTF-8?q?=20and=20=F0=9F=94=8D=20Search...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pysimplesql/pysimplesql.py | 207 ++++++++++++++++++++++++++----------- 1 file changed, 149 insertions(+), 58 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 97547b88..7686522e 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -54,6 +54,7 @@ from __future__ import annotations # docstrings +import abc import asyncio import calendar import contextlib @@ -2290,7 +2291,7 @@ def current_row_has_backup(self) -> bool: :returns: True if a backup row is present that matches, and False otherwise. """ - if self.rows is None: + if self.rows is None or self.rows.empty: return False if ( isinstance(self.rows.attrs["row_backup"], pd.Series) @@ -3317,6 +3318,15 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: self.map_element( element, self[table], col, where_column, where_value ) + if isinstance( + element, (PlaceholderInput, PlaceholderMultiline) + ) and ( + col in self[table].column_info.names() + and self[table].column_info[col].notnull + ): + element.add_placeholder( + lang.notnull_placeholder, themepack.placeholder_color + ) # Map Selector Element elif element.metadata["type"] == TYPE_SELECTOR: @@ -3484,6 +3494,9 @@ def auto_map_events(self, win: sg.Window) -> None: search_box = f"{search_element}:search_input" if data_key: funct = functools.partial(self[data_key].search, search_box) + self.window[search_box].add_placeholder( + lang.search_placeholder, themepack.placeholder_color + ) # elif event_type==EVENT_SEARCH_DB: elif event_type == EVENT_QUICK_EDIT: referring_table = table @@ -3870,29 +3883,28 @@ def update_fields( # Update Markers # -------------------------------------------------------------------------- - if not isinstance(mapped.element, sg.Text): # not for sg.Text - # Show the Required Record marker if the column has notnull set and - # this is a virtual row - marker_key = mapped.element.key + ":marker" - try: - if mapped.dataset.row_is_virtual(): - # get the column name from the key - col = mapped.column - # get notnull from the column info - if ( - col in mapped.dataset.column_info.names() - and mapped.dataset.column_info[col].notnull - ): - self.window[marker_key].update( - visible=True, - text_color=themepack.marker_required_color, - ) - else: - self.window[marker_key].update(visible=False) - if self.window is not None: - self.window[marker_key].update(visible=False) - except AttributeError: + # Show the Required Record marker if the column has notnull set and + # this is a virtual row + marker_key = mapped.element.key + ":marker" + try: + if mapped.dataset.row_is_virtual(): + # get the column name from the key + col = mapped.column + # get notnull from the column info + if ( + col in mapped.dataset.column_info.names() + and mapped.dataset.column_info[col].notnull + ): + self.window[marker_key].update( + visible=True, + text_color=themepack.marker_required_color, + ) + else: self.window[marker_key].update(visible=False) + if self.window is not None: + self.window[marker_key].update(visible=False) + except AttributeError: + self.window[marker_key].update(visible=False) updated_val = None # If there is a callback for this element, use it @@ -4381,15 +4393,13 @@ def checkbox_to_bool(value): class PlaceholderState(object): - # Author: Miguel Martinez Lopez - __slots__ = ( - "normal_color", - "normal_font", - "placeholder_text", - "placeholder_color", - "placeholder_font", - "with_placeholder", - ) + def __init__(self): + self.normal_color = None + self.normal_font = None + self.placeholder_text = "" + self.placeholder_color = None + self.placeholder_font = None + self.active_placeholder = True def add_placeholder_to( @@ -4439,26 +4449,27 @@ def add_placeholder_to( state.placeholder_color = color state.placeholder_font = font state.placeholder_text = placeholder - state.with_placeholder = True + state.active_placeholder = True if isinstance(widget, tk.Entry): def on_focusin(event, widget=widget, state=state): - if state.with_placeholder: + if state.active_placeholder: widget.delete(0, "end") widget.config(fg=state.normal_color, font=state.normal_font) - state.with_placeholder = False + state.active_placeholder = False def on_focusout(event, widget=widget, state=state): if not widget.get(): widget.insert(0, state.placeholder_text) widget.config(fg=state.placeholder_color, font=state.placeholder_font) - state.with_placeholder = True + state.active_placeholder = True - widget.insert(0, placeholder) - widget.config(fg=color, font=font) + if not widget.get(): + widget.insert(0, placeholder) + widget.config(fg=color, font=font) widget.bind("", on_focusin, "+") widget.bind("", on_focusout, "+") @@ -4466,18 +4477,18 @@ def on_focusout(event, widget=widget, state=state): elif isinstance(widget, tk.Text): def on_focusin(event, widget=widget, state=state): - if state.with_placeholder: + if state.active_placeholder: widget.delete("1.0", "end") widget.config(fg=state.normal_color, font=state.normal_font) - state.with_placeholder = False + state.active_placeholder = False def on_focusout(event, widget=widget, state=state): if not widget.get("1.0", "end-1c"): widget.insert("1.0", state.placeholder_text) widget.config(fg=state.placeholder_color, font=state.placeholder_font) - state.with_placeholder = True + state.active_placeholder = True widget.insert("1.0", placeholder) widget.config(fg=color, font=font) @@ -4493,6 +4504,87 @@ def on_focusout(event, widget=widget, state=state): return state +class AbstractPlaceholder(abc.ABC): + """ + An abstract class for PySimpleGUI text-entry elements that allows for the display of + a placeholder text when the input is empty. + + :param args: Optional arguments to pass to `sg.Element`. + :param kwargs: Optional keyword arguments to pass to `sg.Element`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state = PlaceholderState() + self.placeholder = "" + + def add_placeholder(self, placeholder: str, color: str = None, font: str = None): + """ + Adds a placeholder text to the input. + + :param placeholder: The text to display as placeholder when the input is empty. + :param color: The color of the placeholder text (default None). + :param font: The font of the placeholder text (default None). + """ + self.state = add_placeholder_to(self, placeholder, color, font) + + def update(self, *args, **kwargs): + """ + Updates the input widget with a new value and displays the placeholder text if + the value is empty. + + :param args: Optional arguments to pass to `sg.Element.update`. + :param kwargs: Optional keyword arguments to pass to `sg.Element.update`. + """ + if "value" in kwargs and kwargs["value"] is not None: + # If the value is not None, use it as the new value + value = kwargs.pop("value", None) + elif len(args) > 0 and args[0] is not None: + # If the value is passed as an argument, use it as the new value + value = args[0] + # Remove the value argument from args + args = args[1:] + else: + # Otherwise, use the current value + value = self.get() + + if self.state.active_placeholder and value != "": # noqa PLC1901 + # Replace the placeholder with the new value + super().update(value=value) + self.state.active_placeholder = False + self.Widget.config(fg=self.state.normal_color, font=self.state.normal_font) + elif value == "": # noqa PLC1901 + # If the value is empty, reinsert the placeholder + super().update(value=self.state.placeholder_text, *args, **kwargs) + self.state.active_placeholder = True + self.Widget.config( + fg=self.state.placeholder_color, font=self.state.placeholder_font + ) + else: + super().update(*args, **kwargs) + + def get(self) -> str: + """ + Returns the current value of the input, or an empty string if the input displays + the placeholder text. + + :return: The current value of the input. + """ + if self.state.active_placeholder: + return "" + return super().get() + + +class PlaceholderInput(AbstractPlaceholder, sg.Input): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class PlaceholderMultiline(AbstractPlaceholder, sg.Multiline): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + class Popup: """ @@ -5038,7 +5130,7 @@ class Convenience: def field( field: str, - element: Type[sg.Element] = sg.I, + element: Type[sg.Element] = PlaceholderInput, size: Tuple[int, int] = None, label: str = "", no_label: bool = False, @@ -5109,7 +5201,7 @@ def field( else: first_param = "" - if element == sg.Multiline: + if element in [sg.Multiline, PlaceholderMultiline]: layout_element = element( first_param, key=key, @@ -5156,20 +5248,12 @@ def field( ], pad=(0, 0), ) - if element == sg.Text: # don't show markers for sg.Text - if no_label: - layout = [[sg.Text(" "), layout_element]] - elif label_above: - layout = [[layout_label], [sg.Text(" "), layout_element]] - else: - layout = [[layout_label, sg.Text(" "), layout_element]] + if no_label: + layout = [[layout_marker, layout_element]] + elif label_above: + layout = [[layout_label], [layout_marker, layout_element]] else: - if no_label: - layout = [[layout_marker, layout_element]] - elif label_above: - layout = [[layout_label], [layout_marker, layout_element]] - else: - layout = [[layout_label, layout_marker, layout_element]] + layout = [[layout_label, layout_marker, layout_element]] # Add the quick editor button where appropriate if element == sg.Combo and quick_editor: meta = { @@ -5603,7 +5687,9 @@ def actions( } if type(themepack.search) is bytes: layout += [ - sg.Input("", key=keygen.get(f"{key}search_input"), size=search_size), + PlaceholderInput( + "", key=keygen.get(f"{key}search_input"), size=search_size + ), sg.B( "", key=keygen.get(f"{key}search_button"), @@ -6583,11 +6669,12 @@ class ThemePack: # fmt: on # Markers # ---------------------------------------- - "marker_unsaved": "✱", "unsaved_column_header": "💾", "unsaved_column_width": 3, + "marker_unsaved": "✱", "marker_required": "✱", "marker_required_color": "red2", + "placeholder_color": "grey", # Sorting icons # ---------------------------------------- "marker_sort_asc": "\u25BC", @@ -6715,6 +6802,10 @@ class LanguagePack: # ------------------------------------------------------------------------------ # Text, Varchar, Char, Null Default, used exclusively for description_column "description_column_str_null_default": "New Record", + # Placeholder automatically added to PlaceholderInput/PlaceholderMultiline + # that represent Not-Null fields. + "notnull_placeholder": "*Required", + "search_placeholder": "🔍 Search...", # Prepended to parent description_column "duplicate_prepend": "Copy of ", # ------------------------------------------------------------------------------ From 70794949621b95c6892ceb8f3542eda936d402d0 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 00:18:32 -0400 Subject: [PATCH 29/66] Remove manual add_placeholder_to for search since I pushed a commit to do it automatically --- examples/SQLite_examples/orders.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index 5faa3d55..939a2795 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -321,12 +321,6 @@ # Set the column order for search operations. frm["Orders"].set_search_order(["CustomerID"]) -# Add a placeholder to the search input -ss.add_placeholder_to( - win["Orders:search_input"], - "🔍 Search...", -) - # --------- # MAIN LOOP # --------- From 004f1c88b7159c148911579933578dcf4ce56151 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 11:07:22 -0400 Subject: [PATCH 30/66] Adds placeholder functionality to Combobox --- pysimplesql/pysimplesql.py | 89 ++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 7686522e..bc8ced19 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -267,6 +267,11 @@ def get_val(self): # Return the value portion of the row return self.val + def get_pk_ignore_null(self): + if self.pk == "Null": + return None + return self.pk + def get_instance(self): # Return this instance of the row return self @@ -1806,7 +1811,12 @@ def save_record( continue # convert the data into the correct type using the domain in ColumnInfo - element_val = self.column_info[mapped.column].cast(mapped.element.get()) + if isinstance(mapped.element, sg.Combo): + element_val = self.column_info[mapped.column].cast( + mapped.element.get().get_pk_ignore_null() + ) + else: + element_val = self.column_info[mapped.column].cast(mapped.element.get()) # Looked for keyed elements first if mapped.where_column is not None: @@ -2438,7 +2448,9 @@ def column_likely_in_selector(self, column: str) -> bool: for e in self.selector ) - def combobox_values(self, column_name) -> List[ElementRow] or None: + def combobox_values( + self, column_name, insert_placeholder: bool = True + ) -> List[ElementRow] or None: """ Returns the values to use in a sg.Combobox as a list of ElementRow objects. @@ -2467,7 +2479,10 @@ def process_row(row): return ElementRow(backup[pk_column].tolist(), backup[description]) return ElementRow(row[pk_column], row[description]) - return target_table.rows.apply(process_row, axis=1).tolist() + combobox_values = target_table.rows.apply(process_row, axis=1).tolist() + if insert_placeholder: + combobox_values.insert(0, ElementRow("Null", lang.combo_placeholder)) + return combobox_values def get_related_table_for_column(self, column: str) -> str: """ @@ -3318,15 +3333,15 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: self.map_element( element, self[table], col, where_column, where_value ) - if isinstance( - element, (PlaceholderInput, PlaceholderMultiline) - ) and ( + if isinstance(element, (Input, Multiline)) and ( col in self[table].column_info.names() and self[table].column_info[col].notnull ): element.add_placeholder( lang.notnull_placeholder, themepack.placeholder_color ) + if isinstance(element, Combo): + element._finalize() # Map Selector Element elif element.metadata["type"] == TYPE_SELECTOR: @@ -4504,7 +4519,7 @@ def on_focusout(event, widget=widget, state=state): return state -class AbstractPlaceholder(abc.ABC): +class ElementPlaceholder(abc.ABC): """ An abstract class for PySimpleGUI text-entry elements that allows for the display of a placeholder text when the input is empty. @@ -4575,16 +4590,45 @@ def get(self) -> str: return super().get() -class PlaceholderInput(AbstractPlaceholder, sg.Input): +class Input(ElementPlaceholder, sg.Input): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class PlaceholderMultiline(AbstractPlaceholder, sg.Multiline): +class Multiline(ElementPlaceholder, sg.Multiline): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) +class Combo(sg.Combo): + """ + Custom combobox widget with additional placeholder functionality. + """ + + def __init__(self, *args, **kwargs): + self.values = [] + super().__init__(*args, **kwargs) + + def update(self, *args, **kwargs): + """Copies values to internal values""" + if "values" in kwargs and kwargs["values"] is not None: + # If the value is not None, use it as the new value + self.values = kwargs["values"] + super().update(*args, **kwargs) + + def _on_combobox_selected(self, event): + """Event handler that doesn't allow placeholder to be selected""" + if event.widget.current() == 0 and self.values[0].get_pk() == "Null": + super().update(self.values[1]) + + def _finalize(self): + """ + With PySimpleGUI elements, the Widget is only created after the sg.Window is + finalized. This calls binds `<>` to widget after creation. + """ + self.Widget.bind("<>", self._on_combobox_selected, "+") + + class Popup: """ @@ -5130,7 +5174,7 @@ class Convenience: def field( field: str, - element: Type[sg.Element] = PlaceholderInput, + element: Type[sg.Element] = Input, size: Tuple[int, int] = None, label: str = "", no_label: bool = False, @@ -5171,6 +5215,9 @@ def field( Column, but can be treated as a single Element. """ # TODO: See what the metadata does after initial setup is complete - needed anymore? + element = Input if element == sg.Input else element + element = Multiline if element == sg.Multiline else element + element = Combo if element == sg.Combo else element if use_ttk_buttons is None: use_ttk_buttons = themepack.use_ttk_buttons @@ -5201,7 +5248,7 @@ def field( else: first_param = "" - if element in [sg.Multiline, PlaceholderMultiline]: + if element == Multiline: layout_element = element( first_param, key=key, @@ -5255,7 +5302,7 @@ def field( else: layout = [[layout_label, layout_marker, layout_element]] # Add the quick editor button where appropriate - if element == sg.Combo and quick_editor: + if element == Combo and quick_editor: meta = { "type": TYPE_EVENT, "event_type": EVENT_QUICK_EDIT, @@ -5687,9 +5734,7 @@ def actions( } if type(themepack.search) is bytes: layout += [ - PlaceholderInput( - "", key=keygen.get(f"{key}search_input"), size=search_size - ), + Input("", key=keygen.get(f"{key}search_input"), size=search_size), sg.B( "", key=keygen.get(f"{key}search_button"), @@ -6097,7 +6142,9 @@ def edit(self, event): self.active_edit = True # see if we should use a combobox - combobox_values = self.frm[data_key].combobox_values(column) + combobox_values = self.frm[data_key].combobox_values( + column, insert_placeholder=False + ) if combobox_values: widget_type = TK_COMBOBOX @@ -6802,10 +6849,11 @@ class LanguagePack: # ------------------------------------------------------------------------------ # Text, Varchar, Char, Null Default, used exclusively for description_column "description_column_str_null_default": "New Record", - # Placeholder automatically added to PlaceholderInput/PlaceholderMultiline + # Placeholder automatically added to Input/Multiline # that represent Not-Null fields. "notnull_placeholder": "*Required", "search_placeholder": "🔍 Search...", + "combo_placeholder": "Please select one:", # Prepended to parent description_column "duplicate_prepend": "Copy of ", # ------------------------------------------------------------------------------ @@ -7264,6 +7312,13 @@ def default_row_dict(self, dataset: DataSet) -> dict: # Perhaps our default dict does not yet support this datatype null_default = None + # return "Null" if this is a fk_relationship. + # trick used in Combo for the pk to display placeholder + rels = Relationship.get_relationships(dataset.table) + rel = next((r for r in rels if r.fk_column == c.name), None) + if rel: + null_default = "Null" + # skip primary keys if not c.pk: # put default in description_column From 56bc584aeb290f1c228301eafd0dd9cc535f8597 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 11:16:40 -0400 Subject: [PATCH 31/66] Remove auto-selecting 1st entry if placeholder is clicked After playing with it, I found it a bit surprising. --- pysimplesql/pysimplesql.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index bc8ced19..f2db7c11 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -3340,8 +3340,6 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: element.add_placeholder( lang.notnull_placeholder, themepack.placeholder_color ) - if isinstance(element, Combo): - element._finalize() # Map Selector Element elif element.metadata["type"] == TYPE_SELECTOR: @@ -4616,18 +4614,6 @@ def update(self, *args, **kwargs): self.values = kwargs["values"] super().update(*args, **kwargs) - def _on_combobox_selected(self, event): - """Event handler that doesn't allow placeholder to be selected""" - if event.widget.current() == 0 and self.values[0].get_pk() == "Null": - super().update(self.values[1]) - - def _finalize(self): - """ - With PySimpleGUI elements, the Widget is only created after the sg.Window is - finalized. This calls binds `<>` to widget after creation. - """ - self.Widget.bind("<>", self._on_combobox_selected, "+") - class Popup: From ff115c92629f9b01f8ed596ef4f292aec1e3c144 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 11:25:25 -0400 Subject: [PATCH 32/66] Without the widget.bind, don't need to subclass sg.Combo --- pysimplesql/pysimplesql.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index f2db7c11..cf51fd57 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4598,23 +4598,6 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class Combo(sg.Combo): - """ - Custom combobox widget with additional placeholder functionality. - """ - - def __init__(self, *args, **kwargs): - self.values = [] - super().__init__(*args, **kwargs) - - def update(self, *args, **kwargs): - """Copies values to internal values""" - if "values" in kwargs and kwargs["values"] is not None: - # If the value is not None, use it as the new value - self.values = kwargs["values"] - super().update(*args, **kwargs) - - class Popup: """ @@ -5203,7 +5186,6 @@ def field( # TODO: See what the metadata does after initial setup is complete - needed anymore? element = Input if element == sg.Input else element element = Multiline if element == sg.Multiline else element - element = Combo if element == sg.Combo else element if use_ttk_buttons is None: use_ttk_buttons = themepack.use_ttk_buttons @@ -5288,7 +5270,7 @@ def field( else: layout = [[layout_label, layout_marker, layout_element]] # Add the quick editor button where appropriate - if element == Combo and quick_editor: + if element == sg.Combo and quick_editor: meta = { "type": TYPE_EVENT, "event_type": EVENT_QUICK_EDIT, From c78938e29a55a7ef9c8c39081f40900da2fb27dc Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 13:41:26 -0400 Subject: [PATCH 33/66] Cleaning up combobox placeholder implementation Added support for regular non fk combobox into LiveUpdate. And when saving. --- pysimplesql/pysimplesql.py | 42 ++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index cf51fd57..c196025f 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -181,9 +181,10 @@ # ---------------------------- # DELETE RETURNS BITMASKS # ---------------------------- -DELETE_FAILED: int = 1 # No result was found -DELETE_RETURNED: int = 2 # A result was found -DELETE_ABORTED: int = 4 # The search was aborted, likely during a callback +# TODO Which ones of these are we actually using? +DELETE_FAILED: int = 1 # Delete failed +DELETE_RETURNED: int = 2 # Delete returned +DELETE_ABORTED: int = 4 # The delete was aborted, likely during a callback DELETE_RECURSION_LIMIT_ERROR: int = 8 # We hit max nested levels # Mysql sets this as 15 when using foreign key CASCADE DELETE @@ -204,6 +205,11 @@ TK_CHECKBUTTON = "Checkbutton" TK_DATEPICKER = "Datepicker" +# -------------- +# Misc Constants +# -------------- +PK_PLACEHOLDER = "Null" + class Boolean(enum.Flag): TRUE = True @@ -267,8 +273,8 @@ def get_val(self): # Return the value portion of the row return self.val - def get_pk_ignore_null(self): - if self.pk == "Null": + def get_pk_ignore_placeholder(self): + if self.pk == PK_PLACEHOLDER: return None return self.pk @@ -1372,7 +1378,7 @@ def search( :param requery_dependents: (optional) Requery dependents after switching records :param skip_prompt_save: (optional) True to skip prompting to save dirty records :param display_message: Displays a message "Search Failed: ...", otherwise is - silent on success. + silent on fail. :returns: One of the following search values: `SEARCH_FAILED`, `SEARCH_RETURNED`, `SEARCH_ABORTED`. """ @@ -1812,9 +1818,16 @@ def save_record( # convert the data into the correct type using the domain in ColumnInfo if isinstance(mapped.element, sg.Combo): - element_val = self.column_info[mapped.column].cast( - mapped.element.get().get_pk_ignore_null() - ) + # try to get ElementRow pk + try: + element_val = self.column_info[mapped.column].cast( + mapped.element.get().get_pk_ignore_placeholder() + ) + # of if plain-ole combobox: + except AttributeError: + element_val = self.column_info[mapped.column].cast( + mapped.element.get() + ) else: element_val = self.column_info[mapped.column].cast(mapped.element.get()) @@ -6602,7 +6615,10 @@ def sync(self, widget, widget_type): # set the value to the parent pk if widget_type == TK_COMBOBOX: combobox_values = dataset.combobox_values(column) - new_value = combobox_values[widget.current()].get_pk() + if combobox_values: + new_value = combobox_values[widget.current()].get_pk() + else: + widget.get() # get cast new value to correct type for col in dataset.column_info: @@ -7280,12 +7296,12 @@ def default_row_dict(self, dataset: DataSet) -> dict: # Perhaps our default dict does not yet support this datatype null_default = None - # return "Null" if this is a fk_relationship. + # return PK_PLACEHOLDER if this is a fk_relationship. # trick used in Combo for the pk to display placeholder rels = Relationship.get_relationships(dataset.table) rel = next((r for r in rels if r.fk_column == c.name), None) if rel: - null_default = "Null" + null_default = PK_PLACEHOLDER # skip primary keys if not c.pk: @@ -7726,7 +7742,7 @@ def generate_where_clause(dataset: DataSet) -> str: # Children without cascade-filtering parent aren't displayed if not parent_pk: - parent_pk = "NULL" + parent_pk = PK_PLACEHOLDER clause = f" WHERE {table}.{r.fk_column}={str(parent_pk)}" if where: From d1d4d3d145321a703101c4386604b11b209e1042 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 14:24:52 -0400 Subject: [PATCH 34/66] Don't clobber types --- pysimplesql/pysimplesql.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index c196025f..7207a660 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1658,7 +1658,8 @@ def get_current_row(self) -> Union[pd.Series, None]: # For child reparenting self.current_index = self.current_index - return self.rows.iloc[self.current_index] + # make sure to return as python type + return self.rows.astype('O').iloc[self.current_index] return None def add_selector( @@ -2435,7 +2436,8 @@ def process_row(row): return TableRow(pk, lst) - return self.rows.fillna("").apply(process_row, axis=1) + # fill in nan, and display as python types. + return self.rows.fillna("").astype('O').apply(process_row, axis=1) def column_likely_in_selector(self, column: str) -> bool: """ @@ -6618,7 +6620,7 @@ def sync(self, widget, widget_type): if combobox_values: new_value = combobox_values[widget.current()].get_pk() else: - widget.get() + new_value = widget.get() # get cast new value to correct type for col in dataset.column_info: From b098a78d132202777b323b6db3efc28705633b45 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 14:26:08 -0400 Subject: [PATCH 35/66] black fix --- pysimplesql/pysimplesql.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 7207a660..437cbcc5 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1659,7 +1659,7 @@ def get_current_row(self) -> Union[pd.Series, None]: self.current_index = self.current_index # make sure to return as python type - return self.rows.astype('O').iloc[self.current_index] + return self.rows.astype("O").iloc[self.current_index] return None def add_selector( @@ -2437,7 +2437,7 @@ def process_row(row): return TableRow(pk, lst) # fill in nan, and display as python types. - return self.rows.fillna("").astype('O').apply(process_row, axis=1) + return self.rows.fillna("").astype("O").apply(process_row, axis=1) def column_likely_in_selector(self, column: str) -> bool: """ From 408562a17c97f2bf649170750f57ee86eab9a0f7 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 15:26:42 -0400 Subject: [PATCH 36/66] Modernize quick_editor Adds TableHeadings Add sg.Combo for fks, add checkboxes for booleans. Cleanup look and feel --- examples/SQLite_examples/orders.py | 12 ++++++-- pysimplesql/pysimplesql.py | 48 +++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index 939a2795..bddcba60 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -218,10 +218,14 @@ # Define the columns for the table selector using the TableHeading class. order_heading = ss.TableHeadings( - sort_enable=True, # Click a heading to sort - edit_enable=True, # Double-click a cell to make edits. - # Click 💾 in sg.Table Heading to trigger DataSet.save_record() + # Click a heading to sort + sort_enable=True, + + # Double-click a cell to make edits. # Exempted: Primary Key columns, Generated columns, and columns set as readonly + edit_enable=True, + + # Click 💾 in sg.Table Heading to trigger DataSet.save_record() save_enable=True, ) @@ -260,6 +264,7 @@ details_heading.add_column("SubTotal", "SubTotal", 10) orderdetails_layout = [ + [sg.Sizer(h_pixels=0, v_pixels=10)], [ss.field("Orders.CustomerID", sg.Combo, label="Customer")], [ ss.field("Orders.OrderDate", label="Date"), @@ -279,6 +284,7 @@ [ss.field("OrderDetails.Quantity")], [ss.field("OrderDetails.Price", sg.Text)], [ss.field("OrderDetails.SubTotal", sg.Text)], + [sg.Sizer(h_pixels=0, v_pixels=10)], ] layout.append([sg.Frame("Order Details", orderdetails_layout, expand_x=True)]) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 437cbcc5..7bb3d5c9 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2544,12 +2544,15 @@ def quick_editor( keygen.reset() data_key = self.key layout = [] - headings = self.column_info.names() - visible = [1] * len(headings) - visible[0] = 0 - col_width = int(55 / (len(headings) - 1)) - for i in range(0, len(headings)): - headings[i] = headings[i].ljust(col_width, " ") + headings = TableHeadings(sort_enable=True, edit_enable=True, save_enable=True) + + for col in self.column_info.names(): + # set widths + width = int(55 / (len(self.column_info.names()) - 1)) + if col == self.pk_column: + # make pk column either max length of contained pks, or len of name + width = max(self.rows[col].astype(str).map(len).max(), len(col)) + headings.add_column(col, col.capitalize().ljust(width, " "), width=width) layout.append( [ @@ -2558,18 +2561,41 @@ def quick_editor( sg.Table, key=f"{data_key}:quick_editor", num_rows=10, + row_height=25, headings=headings, - visible_column_map=visible, ) ] ) + y_pad = 10 layout.append([actions(data_key, edit_protect=False)]) - layout.append([sg.Text("")]) - layout.append([sg.HorizontalSeparator()]) + layout.append([sg.Sizer(h_pixels=0, v_pixels=y_pad)]) + + fields_layout = [[sg.Sizer(h_pixels=0, v_pixels=y_pad)]] + + rels = Relationship.get_relationships(self.table) for col in self.column_info.names(): + found = False column = f"{data_key}.{col}" + # make sure isn't pk if col != self.pk_column: - layout.append([field(column)]) + # display checkboxes + if self.column_info[col]["domain"] in ["BOOLEAN"]: + fields_layout.append([field(column, sg.Checkbox)]) + found = True + break + # or display sg.combos + for rel in rels: + if col == rel.fk_column: + fields_layout.append([field(column, sg.Combo)]) + found = True + break + # otherwise, just display a regular input + if not found: + fields_layout.append([field(column)]) + + fields_layout.append([sg.Sizer(h_pixels=0, v_pixels=y_pad)]) + layout.append([sg.Frame("Fields", fields_layout, expand_x=True)]) + layout.append([sg.Sizer(h_pixels=0, v_pixels=10)]) quick_win = sg.Window( lang.quick_edit_title.format_map(LangFormat(data_key=data_key)), @@ -2580,7 +2606,7 @@ def quick_editor( ttk_theme=themepack.ttk_theme, # Must, otherwise will redraw window icon=themepack.icon, ) - quick_frm = Form(self.frm.driver, bind_window=quick_win) + quick_frm = Form(self.frm.driver, bind_window=quick_win, live_update=True) # Select the current entry to start with if pk_update_funct is not None: From 86ee1a70a7c189d17177a957cee48dba734f2918 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 15:28:28 -0400 Subject: [PATCH 37/66] small nit, makes sorting look wonky --- pysimplesql/pysimplesql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 7bb3d5c9..572436b3 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2552,7 +2552,7 @@ def quick_editor( if col == self.pk_column: # make pk column either max length of contained pks, or len of name width = max(self.rows[col].astype(str).map(len).max(), len(col)) - headings.add_column(col, col.capitalize().ljust(width, " "), width=width) + headings.add_column(col, col.capitalize(), width=width) layout.append( [ From 9632c69f4aaaf0ac4d0ce7a093538d2e572b5422 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 15:29:52 -0400 Subject: [PATCH 38/66] Add space to pk column in quickeditor so that sorting marker shows --- pysimplesql/pysimplesql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 572436b3..ffef6fea 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2551,7 +2551,7 @@ def quick_editor( width = int(55 / (len(self.column_info.names()) - 1)) if col == self.pk_column: # make pk column either max length of contained pks, or len of name - width = max(self.rows[col].astype(str).map(len).max(), len(col)) + width = max(self.rows[col].astype(str).map(len).max(), len(col)+1) headings.add_column(col, col.capitalize(), width=width) layout.append( From 1015458f861577c1f0a58a194c6cd240adf8d27e Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 15:33:41 -0400 Subject: [PATCH 39/66] Disable quick-editor in quick-editor sg.Combo I don't think we want an infinite quick-editor :smiley: --- pysimplesql/pysimplesql.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index ffef6fea..6407cf29 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2551,7 +2551,7 @@ def quick_editor( width = int(55 / (len(self.column_info.names()) - 1)) if col == self.pk_column: # make pk column either max length of contained pks, or len of name - width = max(self.rows[col].astype(str).map(len).max(), len(col)+1) + width = max(self.rows[col].astype(str).map(len).max(), len(col) + 1) headings.add_column(col, col.capitalize(), width=width) layout.append( @@ -2586,7 +2586,9 @@ def quick_editor( # or display sg.combos for rel in rels: if col == rel.fk_column: - fields_layout.append([field(column, sg.Combo)]) + fields_layout.append( + [field(column, sg.Combo, quick_editor=False)] + ) found = True break # otherwise, just display a regular input From b9eb254c00c0f8ea36a227a6ea99fb57e49a711e Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 18 May 2023 16:03:47 -0400 Subject: [PATCH 40/66] black fixes --- examples/SQLite_examples/orders.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index bddcba60..d3d95ffb 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -220,11 +220,9 @@ order_heading = ss.TableHeadings( # Click a heading to sort sort_enable=True, - # Double-click a cell to make edits. # Exempted: Primary Key columns, Generated columns, and columns set as readonly edit_enable=True, - # Click 💾 in sg.Table Heading to trigger DataSet.save_record() save_enable=True, ) From fbe8f1f8f0397f1f5b8d53eee2f3baf734496053 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 19 May 2023 10:08:48 -0400 Subject: [PATCH 41/66] Integrate placeholder functionality directly into subclassed input/multiline --- pysimplesql/pysimplesql.py | 227 +++++++++++++++---------------------- 1 file changed, 94 insertions(+), 133 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 6407cf29..f5465e25 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -3381,7 +3381,8 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: and self[table].column_info[col].notnull ): element.add_placeholder( - lang.notnull_placeholder, themepack.placeholder_color + placeholder=lang.notnull_placeholder, + color=themepack.placeholder_color, ) # Map Selector Element @@ -3551,7 +3552,8 @@ def auto_map_events(self, win: sg.Window) -> None: if data_key: funct = functools.partial(self[data_key].search, search_box) self.window[search_box].add_placeholder( - lang.search_placeholder, themepack.placeholder_color + placeholder=lang.search_placeholder, + color=themepack.placeholder_color, ) # elif event_type==EVENT_SEARCH_DB: elif event_type == EVENT_QUICK_EDIT: @@ -4448,141 +4450,55 @@ def checkbox_to_bool(value): ] -class PlaceholderState(object): - def __init__(self): - self.normal_color = None - self.normal_font = None - self.placeholder_text = "" - self.placeholder_color = None - self.placeholder_font = None - self.active_placeholder = True - - -def add_placeholder_to( - element, placeholder: str, color: str = "grey", font=None -) -> PlaceholderState: - """ - Add a placeholder to the given element. - - This function adds a placeholder to the given tkinter or PySimpleGUI element. - The placeholder text is displayed in the element when the element is empty and - unfocused. When the element is clicked or focused, the placeholder text disappears - and the element becomes blank. When the element loses focus and is still empty, the - placeholder text reappears. - - This function is based on the recipe by Miguel Martinez Lopez, licensed under MIT. - It has been updated to allow tk.Text elements in addition to tk.Entry elements, - and to work with PySimpleGUI elements. - - :param element: A tkinter or PySimpleGUI element to add the placeholder to. - :type element: tkinter.Entry or tkinter.Text or PySimpleGUI.Input or - PySimpleGUI.Multiline - :param placeholder: The text to display as the placeholder. - :param color: The color of the placeholder text, default is 'grey'. - :type color: str - :param font: The font of the placeholder text, default is the same font as the - element. - :type font: str or None - :return: The PlaceholderState object that tracks the state of the placeholder. - :rtype: PlaceholderState - :raises ValueError: If the widget type is not supported. - - """ - if isinstance(element, (sg.Input, sg.Multiline)): - widget = element.Widget - else: - widget = element - - normal_color = widget.cget("fg") - normal_font = widget.cget("font") - - if font is None: - font = normal_font - - state = PlaceholderState() - state.normal_color = normal_color - state.normal_font = normal_font - state.placeholder_color = color - state.placeholder_font = font - state.placeholder_text = placeholder - state.active_placeholder = True - - if isinstance(widget, tk.Entry): - - def on_focusin(event, widget=widget, state=state): - if state.active_placeholder: - widget.delete(0, "end") - widget.config(fg=state.normal_color, font=state.normal_font) - - state.active_placeholder = False - - def on_focusout(event, widget=widget, state=state): - if not widget.get(): - widget.insert(0, state.placeholder_text) - widget.config(fg=state.placeholder_color, font=state.placeholder_font) - - state.active_placeholder = True - - if not widget.get(): - widget.insert(0, placeholder) - widget.config(fg=color, font=font) - - widget.bind("", on_focusin, "+") - widget.bind("", on_focusout, "+") - - elif isinstance(widget, tk.Text): - - def on_focusin(event, widget=widget, state=state): - if state.active_placeholder: - widget.delete("1.0", "end") - widget.config(fg=state.normal_color, font=state.normal_font) - - state.active_placeholder = False - - def on_focusout(event, widget=widget, state=state): - if not widget.get("1.0", "end-1c"): - widget.insert("1.0", state.placeholder_text) - widget.config(fg=state.placeholder_color, font=state.placeholder_font) - - state.active_placeholder = True - - widget.insert("1.0", placeholder) - widget.config(fg=color, font=font) - - widget.bind("", on_focusin, "+") - widget.bind("", on_focusout, "+") - - else: - raise ValueError("Widget type not supported") - - widget.PlaceholderState = state - - return state - - -class ElementPlaceholder(abc.ABC): +class _PlaceholderText(abc.ABC): """ An abstract class for PySimpleGUI text-entry elements that allows for the display of a placeholder text when the input is empty. - - :param args: Optional arguments to pass to `sg.Element`. - :param kwargs: Optional keyword arguments to pass to `sg.Element`. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.state = PlaceholderState() - self.placeholder = "" + self.normal_color = None + self.normal_font = None + self.placeholder_text = "" + self.placeholder_color = None + self.placeholder_font = None + self.active_placeholder = True def add_placeholder(self, placeholder: str, color: str = None, font: str = None): """ - Adds a placeholder text to the input. + Adds a placeholder text to the element. + + The placeholder text is displayed in the element when the element is empty and + unfocused. When the element is clicked or focused, the placeholder text + disappears and the element becomes blank. When the element loses focus and is + still empty, the placeholder text reappears. + + This function is based on the recipe by Miguel Martinez Lopez, licensed under + MIT. It has been updated to work with PySimpleGUI elements. :param placeholder: The text to display as placeholder when the input is empty. :param color: The color of the placeholder text (default None). :param font: The font of the placeholder text (default None). """ - self.state = add_placeholder_to(self, placeholder, color, font) + normal_color = self.widget.cget("fg") + normal_font = self.widget.cget("font") + + if font is None: + font = normal_font + + self.normal_color = normal_color + self.normal_font = normal_font + self.placeholder_color = color + self.placeholder_font = font + self.placeholder_text = placeholder + self.active_placeholder = True + + self._add_binds() + + @abc.abstractmethod + def _add_binds(self): + pass def update(self, *args, **kwargs): """ @@ -4604,18 +4520,16 @@ def update(self, *args, **kwargs): # Otherwise, use the current value value = self.get() - if self.state.active_placeholder and value != "": # noqa PLC1901 + if self.active_placeholder and value != "": # noqa PLC1901 # Replace the placeholder with the new value super().update(value=value) - self.state.active_placeholder = False - self.Widget.config(fg=self.state.normal_color, font=self.state.normal_font) + self.active_placeholder = False + self.Widget.config(fg=self.normal_color, font=self.normal_font) elif value == "": # noqa PLC1901 # If the value is empty, reinsert the placeholder - super().update(value=self.state.placeholder_text, *args, **kwargs) - self.state.active_placeholder = True - self.Widget.config( - fg=self.state.placeholder_color, font=self.state.placeholder_font - ) + super().update(value=self.placeholder_text, *args, **kwargs) + self.active_placeholder = True + self.Widget.config(fg=self.placeholder_color, font=self.placeholder_font) else: super().update(*args, **kwargs) @@ -4626,20 +4540,67 @@ def get(self) -> str: :return: The current value of the input. """ - if self.state.active_placeholder: + if self.active_placeholder: return "" return super().get() -class Input(ElementPlaceholder, sg.Input): +class Input(_PlaceholderText, sg.Input): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + def _add_binds(self): + widget = self.widget + + def on_focusin(event): + if self.active_placeholder: + widget.delete(0, "end") + widget.config(fg=self.normal_color, font=self.normal_font) + + self.active_placeholder = False -class Multiline(ElementPlaceholder, sg.Multiline): + def on_focusout(event): + if not widget.get(): + widget.insert(0, self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) + + self.active_placeholder = True + + if not widget.get(): + widget.insert(0, self.placeholder_text) + widget.config(fg=self.normal_color, font=self.normal_font) + + widget.bind("", on_focusin, "+") + widget.bind("", on_focusout, "+") + + +class Multiline(_PlaceholderText, sg.Multiline): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + def _add_binds(self): + widget = self.widget + + def on_focusin(event): + if self.active_placeholder: + widget.delete("1.0", "end") + widget.config(fg=self.normal_color, font=self.normal_font) + + self.active_placeholder = False + + def on_focusout(event): + if not widget.get("1.0", "end-1c"): + widget.insert("1.0", self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) + + self.active_placeholder = True + + widget.insert("1.0", self.placeholder_text) + widget.config(fg=self.normal_color, font=self.normal_font) + + widget.bind("", on_focusin, "+") + widget.bind("", on_focusout, "+") + class Popup: From d607429684fc19173c9b39192f7dc97963ef362c Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 19 May 2023 13:18:51 -0400 Subject: [PATCH 42/66] Autocomplete for sg.Combo! --- pysimplesql/pysimplesql.py | 93 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index f5465e25..9efba2eb 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4010,7 +4010,7 @@ def update_fields( None, ) # and update element - mapped.element.update(values=combo_vals, readonly=True) + mapped.element.update(values=combo_vals) elif isinstance(mapped.element, sg.Text): rels = Relationship.get_relationships(mapped.dataset.table) @@ -4139,7 +4139,6 @@ def update_selectors( element.update( values=lst, set_to_index=dataset.current_index, - readonly=True, ) # set vertical scroll bar to follow selected element @@ -4602,6 +4601,89 @@ def on_focusout(event): widget.bind("", on_focusout, "+") +class Combo(sg.Combo): + """Customized Combo widget with autocompletion feature.""" + + def __init__(self, *args, **kwargs): + """Initialize the Combo widget.""" + self._completion_list = [] + self._hits = [] + self._hit_index = 0 + self.position = 0 + self.finalized = False + + super().__init__(*args, **kwargs) + + def update(self, *args, **kwargs): + """Update the Combo widget with new values.""" + if "values" in kwargs and kwargs["values"] is not None: + self._completion_list = [str(row) for row in kwargs["values"]] + if not self.finalized: + self.Widget.bind("", self.handle_keyrelease, "+") + self._hits = [] + self._hit_index = 0 + self.position = 0 + super().update(*args, **kwargs) + + def autocomplete(self, delta=0): + """Perform autocompletion based on the current input.""" + if delta: + # Delete text from current position to end + self.Widget.delete(self.position, tk.END) + else: + # Set the position to the length of the current input text + self.position = len(self.Widget.get()) + + prefix = self.Widget.get() + _hits = [ + element for element in self._completion_list if element.startswith(prefix) + ] + # Create a list of elements that start with the prefix + + if _hits: + common_prefix = os.path.commonprefix(_hits) + if prefix != common_prefix: + # Insert the common prefix at the beginning, move the cursor to the end + self.Widget.delete(0, tk.END) + self.Widget.insert(0, common_prefix) + self.Widget.icursor(len(common_prefix)) + + # Highlight the remaining text after the common prefix + self.Widget.select_range(self.position, tk.END) + + if len(_hits) == 1 and common_prefix != prefix: + # If there is only one hit and it's not equal to the prefix + self.Widget.event_generate("") # Open the dropdown + + else: + # If there are no hits, move the cursor to the current position + self.Widget.icursor(self.position) + + self._hits = _hits + self._hit_index = 0 + + def handle_keyrelease(self, event): + """Handle key release event for autocompletion and navigation.""" + if event.keysym == "BackSpace": + self.Widget.delete(self.Widget.index(tk.INSERT), tk.END) + self.position = self.Widget.index(tk.END) + if event.keysym == "Left": + if self.position < self.Widget.index(tk.END): + self.Widget.delete(self.position, tk.END) + else: + self.position -= 1 + self.Widget.delete(self.position, tk.END) + if event.keysym == "Right": + self.position = self.Widget.index(tk.END) + if event.keysym == "Return": + self.Widget.icursor(tk.END) + self.Widget.selection_clear() + return + + if len(event.keysym) == 1: + self.autocomplete() + + class Popup: """ @@ -5190,6 +5272,7 @@ def field( # TODO: See what the metadata does after initial setup is complete - needed anymore? element = Input if element == sg.Input else element element = Multiline if element == sg.Multiline else element + element = Combo if element == sg.Combo else element if use_ttk_buttons is None: use_ttk_buttons = themepack.use_ttk_buttons @@ -5274,7 +5357,7 @@ def field( else: layout = [[layout_label, layout_marker, layout_element]] # Add the quick editor button where appropriate - if element == sg.Combo and quick_editor: + if element == Combo and quick_editor: meta = { "type": TYPE_EVENT, "event_type": EVENT_QUICK_EDIT, @@ -5763,6 +5846,7 @@ def selector( :param kwargs: Any additional arguments supplied will be passed on to the PySimpleGUI element. """ + element = Combo if element == sg.Combo else element key = f"{table}:selector" if key is None else key key = keygen.get(key) @@ -5786,12 +5870,11 @@ def selector( key=key, metadata=meta, ) - elif element == sg.Combo: + elif element == Combo: w = themepack.default_element_size[0] layout = element( values=(), size=size or (w, 10), - readonly=True, enable_events=True, key=key, auto_size_text=False, From 3982c15a1ba2ea969b02e3ff1b521a726d0fa4c6 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 19 May 2023 13:44:34 -0400 Subject: [PATCH 43/66] Updated autocomplete, added into CellEdit as well --- pysimplesql/pysimplesql.py | 121 +++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 38 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 9efba2eb..1a3833a9 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4601,6 +4601,41 @@ def on_focusout(event): widget.bind("", on_focusout, "+") +def _autocomplete_combo(widget, completion_list, delta=0): + """Perform autocompletion on a Combobox widget based on the current input.""" + if delta: + # Delete text from current position to end + widget.delete(widget.position, tk.END) + else: + # Set the position to the length of the current input text + widget.position = len(widget.get()) + + prefix = widget.get() + _hits = [element for element in completion_list if element.startswith(prefix)] + # Create a list of elements that start with the prefix + + if _hits: + closest_match = min(_hits, key=len) + if prefix != closest_match: + # Insert the closest match at the beginning, move the cursor to the end + widget.delete(0, tk.END) + widget.insert(0, closest_match) + widget.icursor(len(closest_match)) + + # Highlight the remaining text after the closest match + widget.select_range(widget.position, tk.END) + + if len(_hits) == 1 and closest_match != prefix: + # If there is only one hit and it's not equal to the prefix, open dropdown + widget.event_generate("") + + else: + # If there are no hits, move the cursor to the current position + widget.icursor(widget.position) + + return _hits + + class Combo(sg.Combo): """Customized Combo widget with autocompletion feature.""" @@ -4627,46 +4662,14 @@ def update(self, *args, **kwargs): def autocomplete(self, delta=0): """Perform autocompletion based on the current input.""" - if delta: - # Delete text from current position to end - self.Widget.delete(self.position, tk.END) - else: - # Set the position to the length of the current input text - self.position = len(self.Widget.get()) - - prefix = self.Widget.get() - _hits = [ - element for element in self._completion_list if element.startswith(prefix) - ] - # Create a list of elements that start with the prefix - - if _hits: - common_prefix = os.path.commonprefix(_hits) - if prefix != common_prefix: - # Insert the common prefix at the beginning, move the cursor to the end - self.Widget.delete(0, tk.END) - self.Widget.insert(0, common_prefix) - self.Widget.icursor(len(common_prefix)) - - # Highlight the remaining text after the common prefix - self.Widget.select_range(self.position, tk.END) - - if len(_hits) == 1 and common_prefix != prefix: - # If there is only one hit and it's not equal to the prefix - self.Widget.event_generate("") # Open the dropdown - - else: - # If there are no hits, move the cursor to the current position - self.Widget.icursor(self.position) - - self._hits = _hits + self._hits = _autocomplete_combo(self.Widget, self._completion_list, delta) self._hit_index = 0 def handle_keyrelease(self, event): """Handle key release event for autocompletion and navigation.""" if event.keysym == "BackSpace": - self.Widget.delete(self.Widget.index(tk.INSERT), tk.END) - self.position = self.Widget.index(tk.END) + self.Widget.delete(self.Widget.position, tk.END) + self.position = self.Widget.position if event.keysym == "Left": if self.position < self.Widget.index(tk.END): self.Widget.delete(self.position, tk.END) @@ -6268,10 +6271,9 @@ def edit(self, event): # combobox if widget_type == TK_COMBOBOX: - self.field = ttk.Combobox( - frame, textvariable=field_var, justify="left", state="readonly" + self.field = _CellEditCombo( + frame, textvariable=field_var, justify="left", values=combobox_values ) - self.field["values"] = combobox_values self.field.bind("", self.combo_configure) expand = True @@ -6324,6 +6326,9 @@ def edit(self, event): self.field.select_range(0, tk.END) self.field.focus_force() + if widget_type == TK_COMBOBOX: + self.field.bind("", self.field.handle_keyrelease, "+") + # bind single-clicks self.destroy_bind = self.frm.window.TKroot.bind( "", @@ -6469,6 +6474,46 @@ def combo_configure(self, event): combo.configure(style="SS.TCombobox") +class _CellEditCombo(ttk.Combobox): + """Customized Combo widget with autocompletion feature.""" + + def __init__(self, *args, **kwargs): + """Initialize the Combo widget.""" + self._completion_list = [str(row) for row in kwargs["values"]] + self._hits = [] + self._hit_index = 0 + self.position = 0 + self.finalized = False + + super().__init__(*args, **kwargs) + + def autocomplete(self, delta=0): + """Perform autocompletion based on the current input.""" + self._hits = _autocomplete_combo(self, self._completion_list, delta) + self._hit_index = 0 + + def handle_keyrelease(self, event): + """Handle key release event for autocompletion and navigation.""" + if event.keysym == "BackSpace": + self.delete(self.position, tk.END) + self.position = self.position + if event.keysym == "Left": + if self.position < self.index(tk.END): + self.delete(self.position, tk.END) + else: + self.position -= 1 + self.delete(self.position, tk.END) + if event.keysym == "Right": + self.position = self.index(tk.END) + if event.keysym == "Return": + self.icursor(tk.END) + self.selection_clear() + return + + if len(event.keysym) == 1: + self.autocomplete() + + class TtkCalendar(ttk.Frame): """Internal Class.""" From ba7968177d6b3222dfb98b5e29a076e7ffd23b08 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 19 May 2023 14:36:35 -0400 Subject: [PATCH 44/66] Fixes for autocorrect and liveupdate meshing --- examples/SQLite_examples/orders.py | 3 ++- pysimplesql/pysimplesql.py | 26 ++++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index d3d95ffb..b06fb565 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -354,8 +354,9 @@ frm.update_selectors("Orders") # will need to requery if updating, rather than inserting a new record if not row_is_virtual: + pk = current_row[dataset.pk_column] dataset.requery(select_first=False) - frm.update_elements("OrderDetails") + dataset.set_by_pk(pk, skip_prompt_save=True) # ---------------------------------------------------- # Display the quick_editor for products and customers diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 1a3833a9..52508272 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4628,6 +4628,7 @@ def _autocomplete_combo(widget, completion_list, delta=0): if len(_hits) == 1 and closest_match != prefix: # If there is only one hit and it's not equal to the prefix, open dropdown widget.event_generate("") + widget.event_generate("<>") else: # If there are no hits, move the cursor to the current position @@ -6710,12 +6711,18 @@ def __call__(self, event): # get widget type widget_type = event.widget.__class__.__name__ - # immediately sync combo/checkboxs - if widget_type in ["Combobox", "Checkbutton"]: + # if <> and a combobox... + if event.type == "35" and widget_type == "Combobox": + self.frm.window.TKroot.after( + int(self.delay_seconds * 500), + lambda: self.sync(event.widget, widget_type), + ) + + elif widget_type == "Checkbutton": self.sync(event.widget, widget_type) # use tk.after() for text, so waits for pause in typing to update selector. - if widget_type in ["Entry", "Text"]: + elif widget_type in ["Entry", "Text"]: self.frm.window.TKroot.after( int(self.delay_seconds * 1000), lambda: self.delay(event.widget, widget_type), @@ -6727,18 +6734,13 @@ def sync(self, widget, widget_type): data_key = e["table"] column = e["column"] element = e["element"] - new_value = element.get() + if widget_type == TK_COMBOBOX and isinstance(element.get(), ElementRow): + new_value = element.get().get_pk() + else: + new_value = element.get() dataset = self.frm[data_key] - # set the value to the parent pk - if widget_type == TK_COMBOBOX: - combobox_values = dataset.combobox_values(column) - if combobox_values: - new_value = combobox_values[widget.current()].get_pk() - else: - new_value = widget.get() - # get cast new value to correct type for col in dataset.column_info: if col["name"] == column: From 5e42b00926bfd6d35b4333364520d858f3c2d2bb Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 19 May 2023 14:57:39 -0400 Subject: [PATCH 45/66] Fix for Placeholder --- pysimplesql/pysimplesql.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 52508272..122bf8b9 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4559,15 +4559,15 @@ def on_focusin(event): self.active_placeholder = False def on_focusout(event): - if not widget.get(): + if widget.get() == "": # noqa PLC1901 widget.insert(0, self.placeholder_text) widget.config(fg=self.placeholder_color, font=self.placeholder_font) self.active_placeholder = True - if not widget.get(): + if widget.get() == "" and self.active_placeholder: # noqa PLC1901 widget.insert(0, self.placeholder_text) - widget.config(fg=self.normal_color, font=self.normal_font) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) widget.bind("", on_focusin, "+") widget.bind("", on_focusout, "+") @@ -4588,14 +4588,17 @@ def on_focusin(event): self.active_placeholder = False def on_focusout(event): - if not widget.get("1.0", "end-1c"): + if not widget.get("1.0", "end-1c").strip(): widget.insert("1.0", self.placeholder_text) widget.config(fg=self.placeholder_color, font=self.placeholder_font) self.active_placeholder = True - widget.insert("1.0", self.placeholder_text) - widget.config(fg=self.normal_color, font=self.normal_font) + if ( + not widget.get("1.0", "end-1c").strip() and self.active_placeholder + ): # noqa PLC1901 + widget.insert("1.0", self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) widget.bind("", on_focusin, "+") widget.bind("", on_focusout, "+") From 34535d218f050ec8a20022b480bae27c0ceb822a Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 23 May 2023 01:16:59 -0400 Subject: [PATCH 46/66] Re-written table_values/combobox_values Much more performant. Before if I had an orders table of 10000 rows, it took 3.75 seconds to call table_values. Now its 0.015! Also changed to use widget.see, so now rows don't jump around when updating the table. --- pysimplesql/pysimplesql.py | 251 ++++++++++++++++++++----------------- 1 file changed, 135 insertions(+), 116 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 122bf8b9..ad58e9e8 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -73,6 +73,7 @@ from tkinter import ttk from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypedDict, Union +import numpy as np import pandas as pd import PySimpleGUI as sg @@ -366,7 +367,7 @@ def parent_virtual(cls, table: str, frm: Form) -> Union[bool, None]: for r in cls.instances: if r.child_table == table and r.on_update_cascade: try: - return frm[r.parent_table].row_is_virtual() + return frm[r.parent_table].pk_is_virtual() except AttributeError: return False return None @@ -901,7 +902,7 @@ def records_changed(self, column: str = None, recursive=True) -> bool: logger.debug(f'Checking if records have changed in table "{self.table}"...') # Virtual rows wills always be considered dirty - if self.row_is_virtual(): + if self.pk_is_virtual(): return True if self.current_row_has_backup and not self.get_current_row().equals( @@ -1411,26 +1412,37 @@ def search( if self.row_count: logger.debug(f"DEBUG: {self.search_order} {self.rows.columns[0]}") + rows = self.rows.copy() + # fill in descriptions for cols in search_order rels = Relationship.get_relationships(self.table) - - def process_row(row): - for col in self.search_order: - for rel in rels: - if col == rel.fk_column: - # change value in row to below - value = self.frm[rel.parent_table].get_description_for_pk( - row[col] - ) - row[col] = value - return row - return None - - rows = self.rows.apply(process_row, axis=1) + for col in self.search_order: + for rel in rels: + if col == rel.fk_column: + parent_df = self.frm[rel.parent_table].rows + parent_pk_column = self.frm[rel.parent_table].pk_column + + # get this before map(), to revert below + parent_current_row = self.frm[ + rel.parent_table + ].get_original_current_row() + condition = rows[col] == parent_current_row[parent_pk_column] + + description_column = self.frm[rel.parent_table].description_column + mapping_dict = parent_df.set_index(parent_pk_column)[ + description_column + ].to_dict() + rows[col] = rows[col].map(mapping_dict) + + # revert any unsaved changes + rows.loc[condition, col] = parent_current_row[description_column] + continue for column in self.search_order: # search through processed rows, looking for search_string - result = rows[rows[column].str.contains(search_string, case=False)] + result = rows[ + rows[column].astype(str).str.contains(str(search_string), case=False) + ] if not result.empty: old_index = self.current_index # grab the first result @@ -1867,7 +1879,7 @@ def save_record( # create diff of columns if not virtual new_dict = current_row.fillna("").to_dict() - if self.row_is_virtual(): + if self.pk_is_virtual(): changed_row_dict = new_dict else: old_dict = self.get_original_current_row().fillna("").to_dict() @@ -1931,7 +1943,7 @@ def save_record( return SAVE_FAIL # Do not show the message in this case else: - if self.row_is_virtual(): + if self.pk_is_virtual(): result = self.driver.insert_record( self.table, self.get_current_pk(), self.pk_column, changed_row_dict ) @@ -1963,13 +1975,13 @@ def save_record( # If child changes parent, move index back and requery/requery_dependents if ( - cascade_fk_changed and not self.row_is_virtual() + cascade_fk_changed and not self.pk_is_virtual() ): # Virtual rows already requery, and have no dependents. self.frm[self.table].requery(select_first=False) # keep spot in table self.frm[self.table].requery_dependents() # Lets refresh our data - if self.row_is_virtual(): + if self.pk_is_virtual(): # Requery so that the new row honors the order clause self.requery(select_first=False, update_elements=False) if update_elements: @@ -2093,7 +2105,7 @@ def delete_record( if answer == "no": return True - if self.row_is_virtual(): + if self.pk_is_virtual(): self.purge_virtual() self.frm.update_elements(self.key) # only need to reset the Insert button @@ -2147,7 +2159,7 @@ def duplicate_record( :returns: None """ # Ensure that there is actually something to duplicate - if not self.row_count or self.row_is_virtual(): + if not self.row_count or self.pk_is_virtual(): return None # callback @@ -2268,28 +2280,20 @@ def get_description_for_pk(self, pk: int) -> Union[str, int, None]: def virtual_pks(self): return self.rows.attrs["virtual"] - def row_is_virtual(self, index: int = None) -> bool: + def pk_is_virtual(self, pk: int = None) -> bool: """ - Check whether the row at `index` is virtual + Check whether pk is virtual - :param index: The index to check. If none is passed, then the current index will - be used. + :param pk: The pk to check. If None, the pk of the current row will be checked. :returns: True or False based on whether the row is virtual """ if not self.row_count: return False - if index is None: - index = self.current_index + if pk is None: + pk = self.get_current_row()[self.pk_column] - try: - pk = self.rows.loc[self.rows.index[index]][self.pk_column] - except IndexError: - return False - - if self.rows is not None and self.row_count: - return bool(pk in self.virtual_pks) - return False + return bool(pk in self.virtual_pks) @property def row_count(self) -> int: @@ -2385,59 +2389,85 @@ def table_values( columns = all_columns if columns is None else columns + rows = self.rows.copy() pk_column = self.pk_column - virtual_row_pks = self.virtual_pks - unsaved_pk_idx = None - if self.current_row_has_backup and not self.get_current_row().equals( - self.get_original_current_row() - ): - unsaved_pk_idx = self.rows.loc[ - self.rows[pk_column] == self.get_current_row()[pk_column] - ].index[0] + if mark_unsaved: + virtual_row_pks = self.virtual_pks + # add pk of current row if it has changes + if self.current_row_has_backup and not self.get_current_row().equals( + self.get_original_current_row() + ): + virtual_row_pks.append( + self.rows.loc[ + self.rows[pk_column] == self.get_current_row()[pk_column], + pk_column, + ].values[0] + ) + # Create a new column 'marker' with the desired values + rows["marker"] = " " + mask = rows[pk_column].isin(virtual_row_pks) + rows.loc[mask, "marker"] = themepack.marker_unsaved + else: + rows["marker"] = " " + + # get fk descriptions rels = Relationship.get_relationships(self.table) + for col in columns: + for rel in rels: + if col == rel.fk_column: + parent_df = self.frm[rel.parent_table].rows + parent_pk_column = self.frm[rel.parent_table].pk_column + + # get this before map(), to revert below + parent_current_row = self.frm[ + rel.parent_table + ].get_original_current_row() + condition = rows[col] == parent_current_row[parent_pk_column] + + # map descriptions to fk column + description_column = self.frm[rel.parent_table].description_column + mapping_dict = parent_df.set_index(parent_pk_column)[ + description_column + ].to_dict() + rows[col] = rows[col].map(mapping_dict) + + # revert any unsaved changes for the single row + rows.loc[condition, col] = parent_current_row[description_column] + continue - bool_columns = [ - column - for column in columns - if self.column_info[column] - and self.column_info[column]["domain"] in ["BOOLEAN"] - ] + # transform bool + if themepack.display_boolean_as_checkbox: + bool_columns = [ + column + for column in columns + if self.column_info[column] + and self.column_info[column]["domain"] in ["BOOLEAN"] + ] + for col in bool_columns: + rows[col] = np.where( + rows[col], themepack.checkbox_true, themepack.checkbox_false + ) - def process_row(row): - lst = [] - pk = row[pk_column] - if mark_unsaved and (pk in virtual_row_pks or unsaved_pk_idx == row.name): - lst.append(themepack.marker_unsaved) - else: - lst.append(" ") - - # only loop through passed-in columns - for col in columns: - if col in bool_columns and themepack.display_boolean_as_checkbox: - row[col] = ( - themepack.checkbox_true - if checkbox_to_bool(row[col]) - else themepack.checkbox_false - ) - lst.append(row[col]) - elif any(rel.fk_column == col for rel in rels): - for rel in rels: - if col == rel.fk_column: - lst.append( - self.frm[rel.parent_table].get_description_for_pk( - row[col] - ) - ) - break - else: - lst.append(row[col]) + # set the pk to the index to use below + rows["pk_idx"] = rows[pk_column].copy() + rows.set_index("pk_idx", inplace=True) - return TableRow(pk, lst) + # insert the marker + columns.insert(0, "marker") - # fill in nan, and display as python types. - return self.rows.fillna("").astype("O").apply(process_row, axis=1) + # resort rows with requested columns + rows = rows[columns] + + # fastest way yet to generate list of TableRows + return [ + TableRow(pk, values.tolist()) + for pk, values in zip( + rows.index, + np.vstack((rows.fillna("").astype("O").values.T, rows.index)).T, + ) + ] def column_likely_in_selector(self, column: str) -> bool: """ @@ -2481,20 +2511,20 @@ def combobox_values( if rel is None: return None - target_table = self.frm[rel.parent_table] - pk_column = target_table.pk_column - description = target_table.description_column + rows = self.frm[rel.parent_table].rows.copy() + pk_column = self.frm[rel.parent_table].pk_column + description = self.frm[rel.parent_table].description_column - backup = None - if target_table.current_row_has_backup: - backup = target_table.get_original_current_row() + # revert to original row (so unsaved changes don't show up in dropdowns) + parent_current_row = self.frm[rel.parent_table].get_original_current_row() + rows.iloc[self.frm[rel.parent_table].current_index] = parent_current_row - def process_row(row): - if backup is not None and backup[pk_column] == row[pk_column]: - return ElementRow(backup[pk_column].tolist(), backup[description]) - return ElementRow(row[pk_column], row[description]) + # fastest way yet to generate this list of ElementRow + combobox_values = [ + ElementRow(*values) + for values in np.column_stack((rows[pk_column], rows[description])) + ] - combobox_values = target_table.rows.apply(process_row, axis=1).tolist() if insert_placeholder: combobox_values.insert(0, ElementRow("Null", lang.combo_placeholder)) return combobox_values @@ -3856,7 +3886,7 @@ def update_actions(self, target_data_key: str = None) -> None: disable = bool( not row_count or self._edit_protect - or self[data_key].row_is_virtual() + or self[data_key].pk_is_virtual() ) win[m["event"]].update(disabled=disable) @@ -3945,7 +3975,7 @@ def update_fields( # this is a virtual row marker_key = mapped.element.key + ":marker" try: - if mapped.dataset.row_is_virtual(): + if mapped.dataset.pk_is_virtual(): # get the column name from the key col = mapped.column # get notnull from the column info @@ -4034,19 +4064,14 @@ def update_fields( # Select the current one pk = mapped.dataset.get_current_pk() - if len(values): + if len(values): # noqa SIM108 # set index to pk index = [[v[0] for v in values].index(pk)] - # calculate pk percentage position - pk_position = index[0] / len(values) else: # if empty index = [] - pk_position = 0 # Update table, and set vertical scroll bar to follow selected element - update_table_element( - self.window, mapped.element, values, index, pk_position - ) + update_table_element(self.window, mapped.element, values, index) continue elif isinstance(mapped.element, (sg.Input, sg.Multiline)): @@ -4175,19 +4200,14 @@ def update_selectors( if len(values): # set to index by pk index = [[v.pk for v in values].index(pk)] - # calculate pk percentage position - pk_position = index[0] / len(values) found = True else: # if empty index = [] - pk_position = 0 logger.debug(f"Selector:: index:{index} found:{found}") # Update table, and set vertical scroll bar to follow - update_table_element( - self.window, element, values, index, pk_position - ) + update_table_element(self.window, element, values, index) def requery_all( self, @@ -4399,7 +4419,6 @@ def update_table_element( element: Type[sg.Table], values: List[TableRow], select_rows: List[int], - vscroll_position: float = None, ) -> None: """ Updates a PySimpleGUI sg.Table with new data and suppresses extra events emitted. @@ -4412,19 +4431,19 @@ def update_table_element( :param element: The sg.Table element to be updated. :param values: A list of table rows to update the sg.Table with. :param select_rows: List of rows to select as if user did. - :param vscroll_position: From 0 to 1.0, the percentage from the top to move - scrollbar to. :returns: None """ # Disable handling for "<>" event - element.Widget.unbind("<>") + element.widget.unbind("<>") # update element element.update(values=values, select_rows=select_rows) - # set vertical scroll bar to follow selected element - # call even for 0.0, so that a 'reset sort' repositions vscroll to top. - if vscroll_position is not None: - element.set_vscroll_position(vscroll_position) + + # make sure row_iid is visible + if len(values): + row_iid = element.tree_ids[select_rows[0]] + element.widget.see(row_iid) + window.refresh() # Event handled and bypassed # Enable handling for "<>" event element.widget.bind("<>", element._treeview_selected) From 9efd236931ac5530bef7387978c84f49c5abe48d Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 23 May 2023 01:20:39 -0400 Subject: [PATCH 47/66] little cleanup --- pysimplesql/pysimplesql.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index ad58e9e8..d62285b5 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4633,11 +4633,11 @@ def _autocomplete_combo(widget, completion_list, delta=0): widget.position = len(widget.get()) prefix = widget.get() - _hits = [element for element in completion_list if element.startswith(prefix)] + hits = [element for element in completion_list if element.startswith(prefix)] # Create a list of elements that start with the prefix - if _hits: - closest_match = min(_hits, key=len) + if hits: + closest_match = min(hits, key=len) if prefix != closest_match: # Insert the closest match at the beginning, move the cursor to the end widget.delete(0, tk.END) @@ -4647,7 +4647,7 @@ def _autocomplete_combo(widget, completion_list, delta=0): # Highlight the remaining text after the closest match widget.select_range(widget.position, tk.END) - if len(_hits) == 1 and closest_match != prefix: + if len(hits) == 1 and closest_match != prefix: # If there is only one hit and it's not equal to the prefix, open dropdown widget.event_generate("") widget.event_generate("<>") @@ -4656,7 +4656,7 @@ def _autocomplete_combo(widget, completion_list, delta=0): # If there are no hits, move the cursor to the current position widget.icursor(widget.position) - return _hits + return hits class Combo(sg.Combo): From 13deea883e2a102aad4e668518f64013837d3fab Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 23 May 2023 12:46:09 -0400 Subject: [PATCH 48/66] Small Fix, and Remove delay for combobox changes Needed to copy virtual_pks, to avoid adding current row change pk to it. Now that we've sped up table_values, a 10000 row table is near instantaneous. --- pysimplesql/pysimplesql.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index d62285b5..e910f910 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2393,7 +2393,7 @@ def table_values( pk_column = self.pk_column if mark_unsaved: - virtual_row_pks = self.virtual_pks + virtual_row_pks = self.virtual_pks.copy() # add pk of current row if it has changes if self.current_row_has_backup and not self.get_current_row().equals( self.get_original_current_row() @@ -6722,7 +6722,7 @@ def __init__(self, frm_reference: Form): self.frm = frm_reference self.last_event_widget = None self.last_event_time = None - self.delay_seconds = 0.5 + self.delay_seconds = 0.25 def __call__(self, event): # keep track of time on same widget @@ -6733,14 +6733,10 @@ def __call__(self, event): # get widget type widget_type = event.widget.__class__.__name__ - # if <> and a combobox... - if event.type == "35" and widget_type == "Combobox": - self.frm.window.TKroot.after( - int(self.delay_seconds * 500), - lambda: self.sync(event.widget, widget_type), - ) - - elif widget_type == "Checkbutton": + # if <> and a combobox, or a checkbutton + if ( + event.type == "35" and widget_type == "Combobox" + ) or widget_type == "Checkbutton": self.sync(event.widget, widget_type) # use tk.after() for text, so waits for pause in typing to update selector. From f457617b603d1244afdc7d5da3963fd82fd0920c Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 23 May 2023 16:54:56 -0400 Subject: [PATCH 49/66] Example update, row -> pk_is_virtual --- examples/SQLite_examples/orders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index b06fb565..ae6bf990 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -348,12 +348,12 @@ current_row = dataset.get_current_row() # after a product and quantity is entered, save and requery if dataset.row_count and current_row["ProductID"] and current_row["Quantity"]: - row_is_virtual = dataset.row_is_virtual() + pk_is_virtual = dataset.pk_is_virtual() dataset.save_record(display_message=False) frm["Orders"].requery(select_first=False) frm.update_selectors("Orders") # will need to requery if updating, rather than inserting a new record - if not row_is_virtual: + if not pk_is_virtual: pk = current_row[dataset.pk_column] dataset.requery(select_first=False) dataset.set_by_pk(pk, skip_prompt_save=True) From e94b09e4f103dfade9d6fffc095507b58c836bce Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 24 May 2023 10:31:04 -0400 Subject: [PATCH 50/66] Widgets dummy class Moved all subclassed elements and tkinter widgets under "Widgets" dummy class, along with celledit/liveupdate. Make the tkinter ones private. --- pysimplesql/pysimplesql.py | 2690 ++++++++++++++++++------------------ 1 file changed, 1360 insertions(+), 1330 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index e910f910..1f2c1f5b 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -202,9 +202,11 @@ # TK/TTK Widget Types # --------------------- TK_ENTRY = "Entry" +TK_TEXT = "Text" TK_COMBOBOX = "Combobox" TK_CHECKBUTTON = "Checkbutton" TK_DATEPICKER = "Datepicker" +TK_COMBOBOX_SELECTED = "35" # -------------- # Misc Constants @@ -4468,248 +4470,6 @@ def checkbox_to_bool(value): ] -class _PlaceholderText(abc.ABC): - """ - An abstract class for PySimpleGUI text-entry elements that allows for the display of - a placeholder text when the input is empty. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.normal_color = None - self.normal_font = None - self.placeholder_text = "" - self.placeholder_color = None - self.placeholder_font = None - self.active_placeholder = True - - def add_placeholder(self, placeholder: str, color: str = None, font: str = None): - """ - Adds a placeholder text to the element. - - The placeholder text is displayed in the element when the element is empty and - unfocused. When the element is clicked or focused, the placeholder text - disappears and the element becomes blank. When the element loses focus and is - still empty, the placeholder text reappears. - - This function is based on the recipe by Miguel Martinez Lopez, licensed under - MIT. It has been updated to work with PySimpleGUI elements. - - :param placeholder: The text to display as placeholder when the input is empty. - :param color: The color of the placeholder text (default None). - :param font: The font of the placeholder text (default None). - """ - normal_color = self.widget.cget("fg") - normal_font = self.widget.cget("font") - - if font is None: - font = normal_font - - self.normal_color = normal_color - self.normal_font = normal_font - self.placeholder_color = color - self.placeholder_font = font - self.placeholder_text = placeholder - self.active_placeholder = True - - self._add_binds() - - @abc.abstractmethod - def _add_binds(self): - pass - - def update(self, *args, **kwargs): - """ - Updates the input widget with a new value and displays the placeholder text if - the value is empty. - - :param args: Optional arguments to pass to `sg.Element.update`. - :param kwargs: Optional keyword arguments to pass to `sg.Element.update`. - """ - if "value" in kwargs and kwargs["value"] is not None: - # If the value is not None, use it as the new value - value = kwargs.pop("value", None) - elif len(args) > 0 and args[0] is not None: - # If the value is passed as an argument, use it as the new value - value = args[0] - # Remove the value argument from args - args = args[1:] - else: - # Otherwise, use the current value - value = self.get() - - if self.active_placeholder and value != "": # noqa PLC1901 - # Replace the placeholder with the new value - super().update(value=value) - self.active_placeholder = False - self.Widget.config(fg=self.normal_color, font=self.normal_font) - elif value == "": # noqa PLC1901 - # If the value is empty, reinsert the placeholder - super().update(value=self.placeholder_text, *args, **kwargs) - self.active_placeholder = True - self.Widget.config(fg=self.placeholder_color, font=self.placeholder_font) - else: - super().update(*args, **kwargs) - - def get(self) -> str: - """ - Returns the current value of the input, or an empty string if the input displays - the placeholder text. - - :return: The current value of the input. - """ - if self.active_placeholder: - return "" - return super().get() - - -class Input(_PlaceholderText, sg.Input): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def _add_binds(self): - widget = self.widget - - def on_focusin(event): - if self.active_placeholder: - widget.delete(0, "end") - widget.config(fg=self.normal_color, font=self.normal_font) - - self.active_placeholder = False - - def on_focusout(event): - if widget.get() == "": # noqa PLC1901 - widget.insert(0, self.placeholder_text) - widget.config(fg=self.placeholder_color, font=self.placeholder_font) - - self.active_placeholder = True - - if widget.get() == "" and self.active_placeholder: # noqa PLC1901 - widget.insert(0, self.placeholder_text) - widget.config(fg=self.placeholder_color, font=self.placeholder_font) - - widget.bind("", on_focusin, "+") - widget.bind("", on_focusout, "+") - - -class Multiline(_PlaceholderText, sg.Multiline): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def _add_binds(self): - widget = self.widget - - def on_focusin(event): - if self.active_placeholder: - widget.delete("1.0", "end") - widget.config(fg=self.normal_color, font=self.normal_font) - - self.active_placeholder = False - - def on_focusout(event): - if not widget.get("1.0", "end-1c").strip(): - widget.insert("1.0", self.placeholder_text) - widget.config(fg=self.placeholder_color, font=self.placeholder_font) - - self.active_placeholder = True - - if ( - not widget.get("1.0", "end-1c").strip() and self.active_placeholder - ): # noqa PLC1901 - widget.insert("1.0", self.placeholder_text) - widget.config(fg=self.placeholder_color, font=self.placeholder_font) - - widget.bind("", on_focusin, "+") - widget.bind("", on_focusout, "+") - - -def _autocomplete_combo(widget, completion_list, delta=0): - """Perform autocompletion on a Combobox widget based on the current input.""" - if delta: - # Delete text from current position to end - widget.delete(widget.position, tk.END) - else: - # Set the position to the length of the current input text - widget.position = len(widget.get()) - - prefix = widget.get() - hits = [element for element in completion_list if element.startswith(prefix)] - # Create a list of elements that start with the prefix - - if hits: - closest_match = min(hits, key=len) - if prefix != closest_match: - # Insert the closest match at the beginning, move the cursor to the end - widget.delete(0, tk.END) - widget.insert(0, closest_match) - widget.icursor(len(closest_match)) - - # Highlight the remaining text after the closest match - widget.select_range(widget.position, tk.END) - - if len(hits) == 1 and closest_match != prefix: - # If there is only one hit and it's not equal to the prefix, open dropdown - widget.event_generate("") - widget.event_generate("<>") - - else: - # If there are no hits, move the cursor to the current position - widget.icursor(widget.position) - - return hits - - -class Combo(sg.Combo): - """Customized Combo widget with autocompletion feature.""" - - def __init__(self, *args, **kwargs): - """Initialize the Combo widget.""" - self._completion_list = [] - self._hits = [] - self._hit_index = 0 - self.position = 0 - self.finalized = False - - super().__init__(*args, **kwargs) - - def update(self, *args, **kwargs): - """Update the Combo widget with new values.""" - if "values" in kwargs and kwargs["values"] is not None: - self._completion_list = [str(row) for row in kwargs["values"]] - if not self.finalized: - self.Widget.bind("", self.handle_keyrelease, "+") - self._hits = [] - self._hit_index = 0 - self.position = 0 - super().update(*args, **kwargs) - - def autocomplete(self, delta=0): - """Perform autocompletion based on the current input.""" - self._hits = _autocomplete_combo(self.Widget, self._completion_list, delta) - self._hit_index = 0 - - def handle_keyrelease(self, event): - """Handle key release event for autocompletion and navigation.""" - if event.keysym == "BackSpace": - self.Widget.delete(self.Widget.position, tk.END) - self.position = self.Widget.position - if event.keysym == "Left": - if self.position < self.Widget.index(tk.END): - self.Widget.delete(self.position, tk.END) - else: - self.position -= 1 - self.Widget.delete(self.position, tk.END) - if event.keysym == "Right": - self.position = self.Widget.index(tk.END) - if event.keysym == "Return": - self.Widget.icursor(tk.END) - self.Widget.selection_clear() - return - - if len(event.keysym) == 1: - self.autocomplete() - - class Popup: """ @@ -5227,923 +4987,490 @@ def reset_from_form(self, frm: Form) -> None: # ------------------------------------------------------------------------------------- -# CONVENIENCE FUNCTIONS +# WIDGETS # ------------------------------------------------------------------------------------- -# Convenience functions aide in building PySimpleGUI interfaces -# that work well with pysimplesql. -# TODO: How to save Form in metadata? Perhaps give forms names and reference them? -# For example - give forms names! and reference them by name string -# They could even be converted later to a real form during form creation? +class Widgets: + """ + pysimplesql extends several PySimpleGUI elements with further functionality. + See `Input`, `Multiline` and `Combo`. -# This is a dummy class for documenting convenience functions -class Convenience: - - """ - Convenience functions are a collection of functions and classes that aide in - building PySimpleGUI layouts that conform to pysimplesql standards so that your - database application is up and running quickly, and with all the great automatic - functionality pysimplesql has to offer. See the documentation for the following - convenience functions: `field()`, `selector()`, `actions()`, `TableHeadings`. - - Note: This is a dummy class that exists purely to enhance documentation and has no - use to the end user. + Note: This is a dummy class that exists purely to enhance documentation and has no + use to the end user. """ pass -def field( - field: str, - element: Type[sg.Element] = Input, - size: Tuple[int, int] = None, - label: str = "", - no_label: bool = False, - label_above: bool = False, - quick_editor: bool = True, - filter=None, - key=None, - use_ttk_buttons=None, - pad=None, - **kwargs, -) -> sg.Column: +class _PlaceholderText(abc.ABC): """ - Convenience function for adding PySimpleGUI elements to the Window, so they are - properly configured for pysimplesql. The automatic functionality of pysimplesql - relies on accompanying metadata so that the `Form.auto_add_elements()` can pick them - up. This convenience function will create a text label, along with an element with - the above metadata already set up for you. Note: The element key will default to the - record name if none is supplied. See `set_label_size()`, `set_element_size()` and - `set_mline_size()` for setting default sizes of these elements. - - :param field: The database record in the form of table.column I.e. 'Journal.entry' - :param element: (optional) The element type desired (defaults to PySimpleGUI.Input) - :param size: Overrides the default element size that was set with - `set_element_size()` for this element only. - :param label: The text/label will automatically be generated from the column name. - If a different text/label is desired, it can be specified here. - :param no_label: Do not automatically generate a label for this element - :param label_above: Place the label above the element instead of to the left. - :param quick_editor: For records that reference another table, place a quick edit - button next to the element - :param filter: Can be used to reference different `Form`s in the same layout. Use a - matching filter when creating the `Form` with the filter parameter. - :param key: (optional) The key to give this element. See note above about the - default auto generated key. - :param kwargs: Any additional arguments will be passed to the PySimpleGUI element. - :returns: Element(s) to be used in the creation of PySimpleGUI layouts. Note that - this function actually creates multiple Elements wrapped in a PySimpleGUI - Column, but can be treated as a single Element. + An abstract class for PySimpleGUI text-entry elements that allows for the display of + a placeholder text when the input is empty. """ - # TODO: See what the metadata does after initial setup is complete - needed anymore? - element = Input if element == sg.Input else element - element = Multiline if element == sg.Multiline else element - element = Combo if element == sg.Combo else element - if use_ttk_buttons is None: - use_ttk_buttons = themepack.use_ttk_buttons - if pad is None: - pad = themepack.quick_editor_button_pad + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.normal_color = None + self.normal_font = None + self.placeholder_text = "" + self.placeholder_color = None + self.placeholder_font = None + self.active_placeholder = True - # if Record imply a where clause (indicated by ?) If so, strip out the info we need - if "?" in field: - table_info, where_info = field.split("?") - label_text = ( - where_info.split("=")[1].replace("fk", "").replace("_", " ").capitalize() - + ":" - ) - else: - table_info = field - label_text = ( - table_info.split(".")[1].replace("fk", "").replace("_", " ").capitalize() - + ":" - ) - table, column = table_info.split(".") + def add_placeholder(self, placeholder: str, color: str = None, font: str = None): + """ + Adds a placeholder text to the element. - key = field if key is None else key - key = keygen.get(key) + The placeholder text is displayed in the element when the element is empty and + unfocused. When the element is clicked or focused, the placeholder text + disappears and the element becomes blank. When the element loses focus and is + still empty, the placeholder text reappears. - if "values" in kwargs: - first_param = kwargs["values"] - del kwargs["values"] # make sure we don't put it in twice - else: - first_param = "" + This function is based on the recipe by Miguel Martinez Lopez, licensed under + MIT. It has been updated to work with PySimpleGUI elements. - if element == Multiline: - layout_element = element( - first_param, - key=key, - size=size or themepack.default_mline_size, - metadata={ - "type": TYPE_RECORD, - "Form": None, - "filter": filter, - "field": field, - "data_key": key, - }, - **kwargs, - ) - else: - layout_element = element( - first_param, - key=key, - size=size or themepack.default_element_size, - metadata={ - "type": TYPE_RECORD, - "Form": None, - "filter": filter, - "field": field, - "data_key": key, - }, - **kwargs, - ) - layout_label = sg.T( - label if label else label_text, - size=themepack.default_label_size, - key=f"{key}:label", - ) - # Marker for required (notnull) records - layout_marker = sg.Column( - [ - [ - sg.T( - themepack.marker_required, - key=f"{key}:marker", - text_color=sg.theme_background_color(), - visible=True, - ) - ] - ], - pad=(0, 0), - ) - if no_label: - layout = [[layout_marker, layout_element]] - elif label_above: - layout = [[layout_label], [layout_marker, layout_element]] - else: - layout = [[layout_label, layout_marker, layout_element]] - # Add the quick editor button where appropriate - if element == Combo and quick_editor: - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_QUICK_EDIT, - "table": table, - "column": column, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.quick_edit) is bytes: - layout[-1].append( - sg.B( - "", - key=keygen.get(f"{key}.quick_edit"), - size=(1, 1), - image_data=themepack.quick_edit, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - ) - ) - else: - layout[-1].append( - sg.B( - themepack.quick_edit, - key=keygen.get(f"{key}.quick_edit"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - ) - ) - # return layout - return sg.Col(layout=layout, pad=(0, 0)) + :param placeholder: The text to display as placeholder when the input is empty. + :param color: The color of the placeholder text (default None). + :param font: The font of the placeholder text (default None). + """ + normal_color = self.widget.cget("fg") + normal_font = self.widget.cget("font") + if font is None: + font = normal_font -def actions( - table: str, - key=None, - default: bool = True, - edit_protect: bool = None, - navigation: bool = None, - insert: bool = None, - delete: bool = None, - duplicate: bool = None, - save: bool = None, - search: bool = None, - search_size: Tuple[int, int] = (30, 1), - bind_return_key: bool = True, - filter: str = None, - use_ttk_buttons: bool = None, - pad=None, - **kwargs, -) -> sg.Column: - """ - Allows for easily adding record navigation and record action elements to the - PySimpleGUI window The navigation elements are generated automatically (first, - previous, next, last and search). The action elements can be customized by - selecting which ones you want generated from the parameters available. This allows - full control over what is available to the user of your database application. Check - out `ThemePacks` to give any of these autogenerated controls a custom look!. + self.normal_color = normal_color + self.normal_font = normal_font + self.placeholder_color = color + self.placeholder_font = font + self.placeholder_text = placeholder + self.active_placeholder = True - Note: By default, the base element keys generated for PySimpleGUI will be - `table:action` using the name of the table passed in the table parameter plus the - action strings below separated by a colon: (I.e. Journal:table_insert) edit_protect, - db_save, table_first, table_previous, table_next, table_last, table_duplicate, - table_insert, table_delete, search_input, search_button. If you supply a key with - the key parameter, then these additional strings will be appended to that key. Also - note that these autogenerated keys also pass through the `KeyGen`, so it's possible - that these keys could be table_last:action!1, table_last:action!2, etc. + self._add_binds() - :param table: The table name that this "element" will provide actions for - :param key: (optional) The base key to give the generated elements - :param default: Default edit_protect, navigation, insert, delete, save and search to - either true or false (defaults to True) The individual keyword arguments will - trump the default parameter. This allows for starting with all actions - defaulting to False, then individual ones can be enabled with True - or the - opposite by defaulting them all to True, and disabling the ones not needed with - False. - :param edit_protect: An edit protection mode to prevent accidental changes in the - database. It is a button that toggles the ability on and off to prevent - accidental changes in the database by enabling/disabling the insert, edit, - duplicate, delete and save buttons. - :param navigation: The standard << < > >> (First, previous, next, last) buttons for - navigation - :param insert: Button to insert new records - :param delete: Button to delete current record - :param duplicate: Button to duplicate current record - :param save: Button to save record. Note that the save button feature saves changes - made to any table, therefore only one save button is needed per window. - :param search: A search Input element. Size can be specified with the `search_size` - parameter - :param search_size: The size of the search input element - :param bind_return_key: Bind the return key to the search button. Defaults to true. - :param filter: Can be used to reference different `Form`s in the same layout. Use a - matching filter when creating the `Form` with the filter parameter. - :param pad: The padding to use for the generated elements. - :returns: An element to be used in the creation of PySimpleGUI layouts. Note that - this is technically multiple elements wrapped in a PySimpleGUI.Column, but acts - as one element for the purpose of layout building. + @abc.abstractmethod + def _add_binds(self): + pass + + def update(self, *args, **kwargs): + """ + Updates the input widget with a new value and displays the placeholder text if + the value is empty. + + :param args: Optional arguments to pass to `sg.Element.update`. + :param kwargs: Optional keyword arguments to pass to `sg.Element.update`. + """ + if "value" in kwargs and kwargs["value"] is not None: + # If the value is not None, use it as the new value + value = kwargs.pop("value", None) + elif len(args) > 0 and args[0] is not None: + # If the value is passed as an argument, use it as the new value + value = args[0] + # Remove the value argument from args + args = args[1:] + else: + # Otherwise, use the current value + value = self.get() + + if self.active_placeholder and value != "": # noqa PLC1901 + # Replace the placeholder with the new value + super().update(value=value) + self.active_placeholder = False + self.Widget.config(fg=self.normal_color, font=self.normal_font) + elif value == "": # noqa PLC1901 + # If the value is empty, reinsert the placeholder + super().update(value=self.placeholder_text, *args, **kwargs) + self.active_placeholder = True + self.Widget.config(fg=self.placeholder_color, font=self.placeholder_font) + else: + super().update(*args, **kwargs) + + def get(self) -> str: + """ + Returns the current value of the input, or an empty string if the input displays + the placeholder text. + + :return: The current value of the input. + """ + if self.active_placeholder: + return "" + return super().get() + + +class Input(_PlaceholderText, sg.Input): + """ + An Input that allows for the display of a placeholder text when empty. """ - if use_ttk_buttons is None: - use_ttk_buttons = themepack.use_ttk_buttons - if pad is None: - pad = themepack.action_button_pad + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - edit_protect = default if edit_protect is None else edit_protect - navigation = default if navigation is None else navigation - insert = default if insert is None else insert - delete = default if delete is None else delete - duplicate = default if duplicate is None else duplicate - save = default if save is None else save - search = default if search is None else search - key = f"{table}:" if key is None else f"{key}:" + def _add_binds(self): + widget = self.widget - layout = [] + def on_focusin(event): + if self.active_placeholder: + widget.delete(0, "end") + widget.config(fg=self.normal_color, font=self.normal_font) - # Form-level events - if edit_protect: - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_EDIT_PROTECT_DB, - "table": None, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.edit_protect) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}edit_protect"), - size=(1, 1), - image_data=themepack.edit_protect, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.edit_protect, - key=keygen.get(f"{key}edit_protect"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - if save: - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_SAVE_DB, - "table": None, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.save) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}db_save"), - image_data=themepack.save, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B(themepack.save, key=keygen.get(f"{key}db_save"), metadata=meta) - ) + self.active_placeholder = False - # DataSet-level events - if navigation: - # first - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_FIRST, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.first) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}table_first"), - size=(1, 1), - image_data=themepack.first, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.first, - key=keygen.get(f"{key}table_first"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - # previous - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_PREVIOUS, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.previous) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}table_previous"), - size=(1, 1), - image_data=themepack.previous, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.previous, - key=keygen.get(f"{key}table_previous"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - # next - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_NEXT, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.next) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}table_next"), - size=(1, 1), - image_data=themepack.next, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.next, - key=keygen.get(f"{key}table_next"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - # last - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_LAST, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.last) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}table_last"), - size=(1, 1), - image_data=themepack.last, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.last, - key=keygen.get(f"{key}table_last"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - if duplicate: - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_DUPLICATE, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.duplicate) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}table_duplicate"), - size=(1, 1), - image_data=themepack.duplicate, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.duplicate, - key=keygen.get(f"{key}table_duplicate"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - if insert: - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_INSERT, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.insert) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}table_insert"), - size=(1, 1), - image_data=themepack.insert, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.insert, - key=keygen.get(f"{key}table_insert"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - if delete: - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_DELETE, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.delete) is bytes: - layout.append( - sg.B( - "", - key=keygen.get(f"{key}table_delete"), - size=(1, 1), - image_data=themepack.delete, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - else: - layout.append( - sg.B( - themepack.delete, - key=keygen.get(f"{key}table_delete"), - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ) - ) - if search: - meta = { - "type": TYPE_EVENT, - "event_type": EVENT_SEARCH, - "table": table, - "column": None, - "function": None, - "Form": None, - "filter": filter, - } - if type(themepack.search) is bytes: - layout += [ - Input("", key=keygen.get(f"{key}search_input"), size=search_size), - sg.B( - "", - key=keygen.get(f"{key}search_button"), - bind_return_key=bind_return_key, - size=(1, 1), - image_data=themepack.search, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ), - ] - else: - layout += [ - sg.Input("", key=keygen.get(f"{key}search_input"), size=search_size), - sg.B( - themepack.search, - key=keygen.get(f"{key}search_button"), - bind_return_key=bind_return_key, - metadata=meta, - use_ttk_buttons=use_ttk_buttons, - pad=pad, - **kwargs, - ), - ] - return sg.Col(layout=[layout], pad=(0, 0)) + def on_focusout(event): + if widget.get() == "": # noqa PLC1901 + widget.insert(0, self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) + self.active_placeholder = True -def selector( - table: str, - element: Type[sg.Element] = sg.LBox, - size: Tuple[int, int] = None, - filter: str = None, - key: str = None, - **kwargs, -) -> sg.Element: - """ - Selectors in pysimplesql are special elements that allow the user to change records - in the database application. For example, Listboxes, Comboboxes and Tables all - provide a convenient way to users to choose which record they want to select. This - convenience function makes creating selectors very quick and as easy as using a - normal PySimpleGUI element. + if widget.get() == "" and self.active_placeholder: # noqa PLC1901 + widget.insert(0, self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) - :param table: The table name in the database that this selector will act on - :param element: The type of element you would like to use as a selector (defaults to - a Listbox) - :param size: The desired size of this selector element - :param filter: Can be used to reference different `Form`s in the same layout. Use a - matching filter when creating the `Form` with the filter parameter. - :param key: (optional) The key to give to this selector. If no key is provided, it - will default to table:selector using the table specified in the table parameter. - This is also passed through the keygen, so if selectors all use the default - name, they will be made unique. ie: Journal:selector!1, Journal:selector!2, etc. - :param kwargs: Any additional arguments supplied will be passed on to the - PySimpleGUI element. + widget.bind("", on_focusin, "+") + widget.bind("", on_focusout, "+") + + +class Multiline(_PlaceholderText, sg.Multiline): + """ + A Multiline that allows for the display of a placeholder text when empty. """ - element = Combo if element == sg.Combo else element - key = f"{table}:selector" if key is None else key - key = keygen.get(key) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - meta = {"type": TYPE_SELECTOR, "table": table, "Form": None, "filter": filter} - if element == sg.Listbox: - layout = element( - values=(), - size=size or themepack.default_element_size, - key=key, - select_mode=sg.LISTBOX_SELECT_MODE_SINGLE, - enable_events=True, - metadata=meta, - ) - elif element == sg.Slider: - layout = element( - enable_events=True, - size=size or themepack.default_element_size, - orientation="h", - disable_number_display=True, - key=key, - metadata=meta, - ) - elif element == Combo: - w = themepack.default_element_size[0] - layout = element( - values=(), - size=size or (w, 10), - enable_events=True, - key=key, - auto_size_text=False, - metadata=meta, - ) - elif element == sg.Table: - # Check if the headings arg is a Table heading... - if isinstance(kwargs["headings"], TableHeadings): - # Overwrite the kwargs from the TableHeading info - kwargs["visible_column_map"] = kwargs["headings"].visible_map() - kwargs["col_widths"] = kwargs["headings"].width_map() - kwargs["auto_size_columns"] = False # let the col_widths handle it - # Store the TableHeadings object in metadata - # to complete setup on auto_add_elements() - meta["TableHeading"] = kwargs["headings"] - else: - required_kwargs = ["headings", "visible_column_map", "num_rows"] - for kwarg in required_kwargs: - if kwarg not in kwargs: - raise RuntimeError( - f"DataSet selectors must use the {kwarg} keyword argument." - ) + def _add_binds(self): + widget = self.widget - # Create other kwargs that are required - kwargs["enable_events"] = True - kwargs["select_mode"] = sg.TABLE_SELECT_MODE_BROWSE - kwargs["justification"] = "left" - - # Make an empty list of values - vals = [[""] * len(kwargs["headings"])] + def on_focusin(event): + if self.active_placeholder: + widget.delete("1.0", "end") + widget.config(fg=self.normal_color, font=self.normal_font) - # Create a narrow column for displaying a * character for virtual rows. - # This will be the 1st column - kwargs["visible_column_map"].insert(0, 1) - if "col_widths" in kwargs: - kwargs["col_widths"].insert(0, themepack.unsaved_column_width) + self.active_placeholder = False - # Change the headings parameter to be a list so - # the heading doesn't display dicts when it first loads - # The TableHeadings instance is already stored in metadata - if isinstance(kwargs["headings"], TableHeadings): - if kwargs["headings"].save_enable: - kwargs["headings"].insert(0, themepack.unsaved_column_header) - else: - kwargs["headings"].insert(0, "") - kwargs["headings"] = kwargs["headings"].heading_names() - else: - kwargs["headings"].insert(0, "") + def on_focusout(event): + if not widget.get("1.0", "end-1c").strip(): + widget.insert("1.0", self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) - layout = element(values=vals, key=key, metadata=meta, **kwargs) - else: - raise RuntimeError(f'Element type "{element}" not supported as a selector.') + self.active_placeholder = True - return layout + if ( + not widget.get("1.0", "end-1c").strip() and self.active_placeholder + ): # noqa PLC1901 + widget.insert("1.0", self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) + widget.bind("", on_focusin, "+") + widget.bind("", on_focusout, "+") -class TableHeadings(list): - """ - This is a convenience class used to build table headings for PySimpleGUI. +def _autocomplete_combo(widget, completion_list, delta=0): + """Perform autocompletion on a Combobox widget based on the current input.""" + if delta: + # Delete text from current position to end + widget.delete(widget.position, tk.END) + else: + # Set the position to the length of the current input text + widget.position = len(widget.get()) - In addition, `TableHeading` objects can sort columns in ascending or descending - order by clicking on the column in the heading in the PySimpleGUI Table element if - the sort_enable parameter is set to True. - """ + prefix = widget.get() + hits = [element for element in completion_list if element.startswith(prefix)] + # Create a list of elements that start with the prefix - # store our instances - instances = [] + if hits: + closest_match = min(hits, key=len) + if prefix != closest_match: + # Insert the closest match at the beginning, move the cursor to the end + widget.delete(0, tk.END) + widget.insert(0, closest_match) + widget.icursor(len(closest_match)) - def __init__( - self, - sort_enable: bool = True, - edit_enable: bool = False, - save_enable: bool = False, - ) -> None: - """ - Create a new TableHeadings object. + # Highlight the remaining text after the closest match + widget.select_range(widget.position, tk.END) - :param sort_enable: True to enable sorting by heading column - :param edit_enable: Enables cell editing if True. Accepted edits update both - `sg.Table` and associated `field` element. - :param save_enable: Enables saving record by double-clicking unsaved marker col. - :returns: None - """ - self.sort_enable = sort_enable - self.edit_enable = edit_enable - self.save_enable = save_enable - self._width_map = [] - self._visible_map = [] - self.readonly_columns = [] + if len(hits) == 1 and closest_match != prefix: + # If there is only one hit and it's not equal to the prefix, open dropdown + widget.event_generate("") + widget.event_generate("<>") - # Store this instance in the master list of instances - TableHeadings.instances.append(self) + else: + # If there are no hits, move the cursor to the current position + widget.icursor(widget.position) - def add_column( - self, - column: str, - heading_column: str, - width: int, - visible: bool = True, - readonly: bool = False, - ) -> None: - """ - Add a new heading column to this TableHeading object. Columns are added in the - order that this method is called. Note that the primary key column does not need - to be included, as primary keys are stored internally in the `TableRow` class. + return hits - :param heading_column: The name of this columns heading (title) - :param column: The name of the column in the database the heading column is for - :param width: The width for this column to display within the Table element - :param visible: True if the column is visible. Typically, the only hidden - column would be the primary key column if any. This is also useful if the - `DataSet.rows` DataFrame has information that you don't want to display. - :param readonly: Indicates if the column is read-only when - `TableHeading.edit_enable` is True. - :returns: None - """ - self.append({"heading": heading_column, "column": column}) - self._width_map.append(width) - self._visible_map.append(visible) - if readonly: - self.readonly_columns.append(column) - def heading_names(self) -> List[str]: - """ - Return a list of heading_names for use with the headings parameter of - PySimpleGUI.Table. +class Combo(sg.Combo): + """Customized Combo widget with autocompletion feature. - :returns: a list of heading names - """ - return [c["heading"] for c in self] + Please note that due to how PySimpleSql initilizes widgets, you must call update() + once to activate autocompletion, eg `window['combo_key'].update(values=values)` + """ - def columns(self): - """ - Return a list of column names. + def __init__(self, *args, **kwargs): + """Initialize the Combo widget.""" + self._completion_list = [] + self._hits = [] + self._hit_index = 0 + self.position = 0 + self.finalized = False - :returns: a list of column names - """ - return [c["column"] for c in self if c["column"] is not None] + super().__init__(*args, **kwargs) - def visible_map(self) -> List[Union[bool, int]]: - """ - Convenience method for creating PySimpleGUI tables. + def update(self, *args, **kwargs): + """Update the Combo widget with new values.""" + if "values" in kwargs and kwargs["values"] is not None: + self._completion_list = [str(row) for row in kwargs["values"]] + if not self.finalized: + self.Widget.bind("", self.handle_keyrelease, "+") + self._hits = [] + self._hit_index = 0 + self.position = 0 + super().update(*args, **kwargs) - :returns: a list of visible columns for use with th PySimpleGUI Table - visible_column_map parameter - """ - return list(self._visible_map) + def autocomplete(self, delta=0): + """Perform autocompletion based on the current input.""" + self._hits = _autocomplete_combo(self.Widget, self._completion_list, delta) + self._hit_index = 0 - def width_map(self) -> List[int]: - """ - Convenience method for creating PySimpleGUI tables. + def handle_keyrelease(self, event): + """Handle key release event for autocompletion and navigation.""" + if event.keysym == "BackSpace": + self.Widget.delete(self.Widget.position, tk.END) + self.position = self.Widget.position + if event.keysym == "Left": + if self.position < self.Widget.index(tk.END): + self.Widget.delete(self.position, tk.END) + else: + self.position -= 1 + self.Widget.delete(self.position, tk.END) + if event.keysym == "Right": + self.position = self.Widget.index(tk.END) + if event.keysym == "Return": + self.Widget.icursor(tk.END) + self.Widget.selection_clear() + return - :returns: a list column widths for use with th PySimpleGUI Table col_widths - parameter - """ - return list(self._width_map) + if len(event.keysym) == 1: + self.autocomplete() - def update_headings( - self, element: sg.Table, sort_column=None, sort_order: int = None - ) -> None: - """ - Perform the actual update to the PySimpleGUI Table heading. - Note: Not typically called by the end user. - :param element: The PySimpleGUI Table element - :param sort_column: The column to show the sort direction indicators on - :param sort_order: A SORT_* constant (SORT_NONE, SORT_ASC, SORT_DESC) - :returns: None - """ +class _TtkCombo(ttk.Combobox): + """Customized Combo widget with autocompletion feature.""" - # Load in our marker characters. We will use them to both display the - # sort direction and to detect current direction - try: - asc = themepack.marker_sort_asc - except AttributeError: - asc = "\u25BC" - try: - desc = themepack.marker_sort_desc - except AttributeError: - desc = "\u25B2" + def __init__(self, *args, **kwargs): + """Initialize the Combo widget.""" + self._completion_list = [str(row) for row in kwargs["values"]] + self._hits = [] + self._hit_index = 0 + self.position = 0 + self.finalized = False - for i, x in zip(range(len(self)), self): - # Clear the direction markers - x["heading"] = x["heading"].replace(asc, "").replace(desc, "") - if ( - x["column"] == sort_column - and sort_column is not None - and sort_order != SORT_NONE - ): - x["heading"] += asc if sort_order == SORT_ASC else desc - element.Widget.heading(i, text=x["heading"], anchor="w") + super().__init__(*args, **kwargs) - def enable_heading_function(self, element: sg.Table, fn: callable) -> None: - """ - Enable the sorting callbacks for each column index, or saving by click the - unsaved changes column - Note: Not typically used by the end user. Called from `Form.auto_map_elements()` + def autocomplete(self, delta=0): + """Perform autocompletion based on the current input.""" + self._hits = _autocomplete_combo(self, self._completion_list, delta) + self._hit_index = 0 - :param element: The PySimpleGUI Table element associated with this TableHeading - :param fn: A callback functions to run when a heading is clicked. The callback - should take one column parameter. - :returns: None - """ - if self.sort_enable: - for i in range(len(self)): - if self[i]["column"] is not None: - element.widget.heading( - i, command=functools.partial(fn, self[i]["column"], False) - ) - self.update_headings(element) - if self.save_enable: - element.widget.heading(0, command=functools.partial(fn, None, save=True)) + def handle_keyrelease(self, event): + """Handle key release event for autocompletion and navigation.""" + if event.keysym == "BackSpace": + self.delete(self.position, tk.END) + self.position = self.position + if event.keysym == "Left": + if self.position < self.index(tk.END): + self.delete(self.position, tk.END) + else: + self.position -= 1 + self.delete(self.position, tk.END) + if event.keysym == "Right": + self.position = self.index(tk.END) + if event.keysym == "Return": + self.icursor(tk.END) + self.selection_clear() + return - def insert(self, idx, heading_column: str, column: str = None, *args, **kwargs): - super().insert(idx, {"heading": heading_column, "column": column}) + if len(event.keysym) == 1: + self.autocomplete() -class _HeadingCallback: +class _TtkCalendar(ttk.Frame): + """Internal Class.""" - """Internal class used when sg.Table column headings are clicked.""" + # Modified from Tkinter GUI Application Development Cookbook, MIT License. - def __init__(self, frm_reference: Form, data_key: str): - """ - Create a new _HeadingCallback object. + def __init__(self, master, init_date, textvariable, **kwargs): + # TODO, set these in themepack? + fwday = kwargs.pop("firstweekday", calendar.MONDAY) + sel_bg = kwargs.pop("selectbackground", "#ecffc4") + sel_fg = kwargs.pop("selectforeground", "#05640e") - :param frm_reference: `Form` object - :param data_key: `DataSet` key - :returns: None - """ - self.frm: Form = frm_reference - self.data_key = data_key + super().__init__(master, class_="ttkcalendar", **kwargs) - def __call__(self, column, save): - if save: - self.frm[self.data_key].save_record() - # force a timeout, without this - # info popup creation broke pysimplegui events, weird! - self.frm.window.read(timeout=1) - else: - self.frm[self.data_key].sort_cycle( - column, self.data_key, update_elements=True - ) + self.master = master + self.cal_date = init_date + self.textvariable = textvariable + self.cal = calendar.TextCalendar(fwday) + self.font = tkfont.Font(self) + self.header = self.create_header() + self.table = self.create_table() + self.canvas = self.create_canvas(sel_bg, sel_fg) + self.build_calendar() + + def create_header(self): + left_arrow = {"children": [("Button.leftarrow", None)]} + right_arrow = {"children": [("Button.rightarrow", None)]} + style = ttk.Style(self) + style.layout("L.TButton", [("Button.focus", left_arrow)]) + style.layout("R.TButton", [("Button.focus", right_arrow)]) + + hframe = ttk.Frame(self) + btn_left = ttk.Button( + hframe, style="L.TButton", command=lambda: self.move_month(-1) + ) + btn_right = ttk.Button( + hframe, style="R.TButton", command=lambda: self.move_month(1) + ) + label = ttk.Label(hframe, width=15, anchor="center") + + hframe.pack(pady=5, anchor=tk.CENTER) + btn_left.grid(row=0, column=0) + label.grid(row=0, column=1, padx=12) + btn_right.grid(row=0, column=2) + return label + + def create_table(self): + cols = self.cal.formatweekheader(3).split() + table = ttk.Treeview(self, show="", selectmode="none", height=7, columns=cols) + table.bind("", self.minsize, "+") + table.pack(expand=1, fill=tk.BOTH) + table.tag_configure("header", background="grey90") + table.insert("", tk.END, values=cols, tag="header") + for _ in range(6): + table.insert("", tk.END) + + width = max(map(self.font.measure, cols)) + for col in cols: + table.column(col, width=width, minwidth=width, anchor=tk.E) + return table + + def create_canvas(self, bg, fg): + canvas = tk.Canvas( + self.table, background=bg, borderwidth=1, highlightthickness=0 + ) + canvas.text = canvas.create_text(0, 0, fill=fg, anchor=tk.W) + self.table.bind("", self.pressed_callback, "+") + return canvas + + def build_calendar(self): + year, month = self.cal_date.year, self.cal_date.month + month_name = self.cal.formatmonthname(year, month, 0) + month_weeks = self.cal.monthdayscalendar(year, month) + + self.header.config(text=month_name.title()) + items = self.table.get_children()[1:] + for week, item in itertools.zip_longest(month_weeks, items): + fmt_week = [f"{day:02d}" if day else "" for day in (week or [])] + self.table.item(item, values=fmt_week) + + def pressed_callback(self, event): + x, y, widget = event.x, event.y, event.widget + item = widget.identify_row(y) + column = widget.identify_column(x) + items = self.table.get_children()[1:] + + if not column or item not in items: + # clicked te header or outside the columns + return + + index = int(column[1]) - 1 + values = widget.item(item)["values"] + text = values[index] if len(values) else None + bbox = widget.bbox(item, column) + if bbox and text: + self.cal_date = dt.date(self.cal_date.year, self.cal_date.month, int(text)) + self.draw_selection(bbox) + self.textvariable.set(self.cal_date.strftime("%Y-%m-%d")) + + def draw_selection(self, bbox): + canvas, text = self.canvas, "%02d" % self.cal_date.day + x, y, width, height = bbox + textw = self.font.measure(text) + canvas.configure(width=width, height=height) + canvas.coords(canvas.text, width - textw, height / 2 - 1) + canvas.itemconfigure(canvas.text, text=text) + canvas.place(x=x, y=y) + + def set_date(self, dateobj): + self.cal_date = dateobj + self.canvas.place_forget() + self.build_calendar() + + def select_date(self): + bbox = self.get_bbox_for_date(self.cal_date) + if bbox: + self.draw_selection(bbox) + + def get_bbox_for_date(self, new_date): + items = self.table.get_children()[1:] + for item in items: + values = self.table.item(item)["values"] + for i, value in enumerate(values): + if isinstance(value, int) and value == new_date.day: + column = "#{}".format(i + 1) + self.table.update() + return self.table.bbox(item, column) + return None + + def move_month(self, offset): + self.canvas.place_forget() + month = self.cal_date.month - 1 + offset + year = self.cal_date.year + month // 12 + month = month % 12 + 1 + self.cal_date = dt.date(year, month, 1) + self.build_calendar() + + def minsize(self, e): + width, height = self.master.geometry().split("x") + height = height[: height.index("+")] + self.master.minsize(width, height) + + +class _DatePicker(ttk.Entry): + def __init__(self, master, frm_reference, init_date, **kwargs): + self.frm = frm_reference + textvariable = kwargs["textvariable"] + self.calendar = _TtkCalendar(self.frm.window.TKroot, init_date, textvariable) + self.calendar.place_forget() + self.button = ttk.Button(master, text="▼", width=2, command=self.show_calendar) + super().__init__(master, class_="Datepicker", **kwargs) + + self.bind("", self.on_entry_key_release, "+") + self.calendar.bind("", self.hide_calendar, "+") + + def show_calendar(self, event=None): + self.configure(state="disabled") + self.calendar.place(in_=self, relx=0, rely=1) + self.calendar.focus_force() + self.calendar.select_date() + + def hide_calendar(self, event=None): + self.configure(state="!disabled") + self.calendar.place_forget() + self.focus_force() + + def on_entry_key_release(self, event=None): + # Check if the user has typed a valid date + try: + date_str = self.get() + date = dt.datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + return + + # Update the calendar to show the new date + self.calendar.set_date(date) class _CellEdit: @@ -6287,14 +5614,14 @@ def edit(self, event): if widget_type == TK_DATEPICKER: text = dt.date.today() if type(text) is str else text - self.field = DatePicker( + self.field = _DatePicker( frame, self.frm, init_date=text, textvariable=field_var ) expand = True # combobox if widget_type == TK_COMBOBOX: - self.field = _CellEditCombo( + self.field = _TtkCombo( frame, textvariable=field_var, justify="left", values=combobox_values ) self.field.bind("", self.combo_configure) @@ -6497,294 +5824,997 @@ def combo_configure(self, event): combo.configure(style="SS.TCombobox") -class _CellEditCombo(ttk.Combobox): - """Customized Combo widget with autocompletion feature.""" +class _LiveUpdate: - def __init__(self, *args, **kwargs): - """Initialize the Combo widget.""" - self._completion_list = [str(row) for row in kwargs["values"]] - self._hits = [] - self._hit_index = 0 - self.position = 0 - self.finalized = False + """Internal class used to automatically sync selectors with field changes""" - super().__init__(*args, **kwargs) + def __init__(self, frm_reference: Form): + self.frm = frm_reference + self.last_event_widget = None + self.last_event_time = None + self.delay_seconds = 0.25 - def autocomplete(self, delta=0): - """Perform autocompletion based on the current input.""" - self._hits = _autocomplete_combo(self, self._completion_list, delta) - self._hit_index = 0 + def __call__(self, event): + # keep track of time on same widget + if event.widget == self.last_event_widget: + self.last_event_time = time() + self.last_event_widget = event.widget - def handle_keyrelease(self, event): - """Handle key release event for autocompletion and navigation.""" - if event.keysym == "BackSpace": - self.delete(self.position, tk.END) - self.position = self.position - if event.keysym == "Left": - if self.position < self.index(tk.END): - self.delete(self.position, tk.END) - else: - self.position -= 1 - self.delete(self.position, tk.END) - if event.keysym == "Right": - self.position = self.index(tk.END) - if event.keysym == "Return": - self.icursor(tk.END) - self.selection_clear() - return + # get widget type + widget_type = event.widget.__class__.__name__ - if len(event.keysym) == 1: - self.autocomplete() + # if <> and a combobox, or a checkbutton + if ( + event.type == TK_COMBOBOX_SELECTED and widget_type == TK_COMBOBOX + ) or widget_type == TK_CHECKBUTTON: + self.sync(event.widget, widget_type) + # use tk.after() for text, so waits for pause in typing to update selector. + elif widget_type in [TK_ENTRY, TK_TEXT]: + self.frm.window.TKroot.after( + int(self.delay_seconds * 1000), + lambda: self.delay(event.widget, widget_type), + ) -class TtkCalendar(ttk.Frame): - """Internal Class.""" + def sync(self, widget, widget_type): + for e in self.frm.element_map: + if e["element"].widget == widget: + data_key = e["table"] + column = e["column"] + element = e["element"] + if widget_type == TK_COMBOBOX and isinstance(element.get(), ElementRow): + new_value = element.get().get_pk() + else: + new_value = element.get() - # Modified from Tkinter GUI Application Development Cookbook, MIT License. + dataset = self.frm[data_key] + + # get cast new value to correct type + for col in dataset.column_info: + if col["name"] == column: + new_value = col.cast(new_value) + break + + # see if there was a change + old_value = dataset.get_current_row()[column] + new_value = dataset.value_changed( + column, old_value, new_value, bool(widget_type == TK_CHECKBUTTON) + ) + if new_value is not Boolean.FALSE: + # push row to dataset and update + dataset.set_current(column, new_value, write_event=True) + + # Update tableview if uses column: + if dataset.column_likely_in_selector(column): + self.frm.update_selectors(dataset.key) + + def delay(self, widget, widget_type): + if self.last_event_time: + elapsed_sec = time() - self.last_event_time + if elapsed_sec >= self.delay_seconds: + self.sync(widget, widget_type) + else: + self.sync(widget, widget_type) + + +# ------------------------------------------------------------------------------------- +# CONVENIENCE FUNCTIONS +# ------------------------------------------------------------------------------------- +# Convenience functions aide in building PySimpleGUI interfaces +# that work well with pysimplesql. +# TODO: How to save Form in metadata? Perhaps give forms names and reference them? +# For example - give forms names! and reference them by name string +# They could even be converted later to a real form during form creation? + + +# This is a dummy class for documenting convenience functions +class Convenience: + + """ + Convenience functions are a collection of functions and classes that aide in + building PySimpleGUI layouts that conform to pysimplesql standards so that your + database application is up and running quickly, and with all the great automatic + functionality pysimplesql has to offer. See the documentation for the following + convenience functions: `field()`, `selector()`, `actions()`, `TableHeadings`. + + Note: This is a dummy class that exists purely to enhance documentation and has no + use to the end user. + """ + + pass + + +def field( + field: str, + element: Type[sg.Element] = Input, + size: Tuple[int, int] = None, + label: str = "", + no_label: bool = False, + label_above: bool = False, + quick_editor: bool = True, + filter=None, + key=None, + use_ttk_buttons=None, + pad=None, + **kwargs, +) -> sg.Column: + """ + Convenience function for adding PySimpleGUI elements to the Window, so they are + properly configured for pysimplesql. The automatic functionality of pysimplesql + relies on accompanying metadata so that the `Form.auto_add_elements()` can pick them + up. This convenience function will create a text label, along with an element with + the above metadata already set up for you. Note: The element key will default to the + record name if none is supplied. See `set_label_size()`, `set_element_size()` and + `set_mline_size()` for setting default sizes of these elements. + + :param field: The database record in the form of table.column I.e. 'Journal.entry' + :param element: (optional) The element type desired (defaults to PySimpleGUI.Input) + :param size: Overrides the default element size that was set with + `set_element_size()` for this element only. + :param label: The text/label will automatically be generated from the column name. + If a different text/label is desired, it can be specified here. + :param no_label: Do not automatically generate a label for this element + :param label_above: Place the label above the element instead of to the left. + :param quick_editor: For records that reference another table, place a quick edit + button next to the element + :param filter: Can be used to reference different `Form`s in the same layout. Use a + matching filter when creating the `Form` with the filter parameter. + :param key: (optional) The key to give this element. See note above about the + default auto generated key. + :param kwargs: Any additional arguments will be passed to the PySimpleGUI element. + :returns: Element(s) to be used in the creation of PySimpleGUI layouts. Note that + this function actually creates multiple Elements wrapped in a PySimpleGUI + Column, but can be treated as a single Element. + """ + # TODO: See what the metadata does after initial setup is complete - needed anymore? + element = Input if element == sg.Input else element + element = Multiline if element == sg.Multiline else element + element = Combo if element == sg.Combo else element + + if use_ttk_buttons is None: + use_ttk_buttons = themepack.use_ttk_buttons + if pad is None: + pad = themepack.quick_editor_button_pad + + # if Record imply a where clause (indicated by ?) If so, strip out the info we need + if "?" in field: + table_info, where_info = field.split("?") + label_text = ( + where_info.split("=")[1].replace("fk", "").replace("_", " ").capitalize() + + ":" + ) + else: + table_info = field + label_text = ( + table_info.split(".")[1].replace("fk", "").replace("_", " ").capitalize() + + ":" + ) + table, column = table_info.split(".") + + key = field if key is None else key + key = keygen.get(key) + + if "values" in kwargs: + first_param = kwargs["values"] + del kwargs["values"] # make sure we don't put it in twice + else: + first_param = "" + + if element == Multiline: + layout_element = element( + first_param, + key=key, + size=size or themepack.default_mline_size, + metadata={ + "type": TYPE_RECORD, + "Form": None, + "filter": filter, + "field": field, + "data_key": key, + }, + **kwargs, + ) + else: + layout_element = element( + first_param, + key=key, + size=size or themepack.default_element_size, + metadata={ + "type": TYPE_RECORD, + "Form": None, + "filter": filter, + "field": field, + "data_key": key, + }, + **kwargs, + ) + layout_label = sg.T( + label if label else label_text, + size=themepack.default_label_size, + key=f"{key}:label", + ) + # Marker for required (notnull) records + layout_marker = sg.Column( + [ + [ + sg.T( + themepack.marker_required, + key=f"{key}:marker", + text_color=sg.theme_background_color(), + visible=True, + ) + ] + ], + pad=(0, 0), + ) + if no_label: + layout = [[layout_marker, layout_element]] + elif label_above: + layout = [[layout_label], [layout_marker, layout_element]] + else: + layout = [[layout_label, layout_marker, layout_element]] + # Add the quick editor button where appropriate + if element == Combo and quick_editor: + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_QUICK_EDIT, + "table": table, + "column": column, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.quick_edit) is bytes: + layout[-1].append( + sg.B( + "", + key=keygen.get(f"{key}.quick_edit"), + size=(1, 1), + image_data=themepack.quick_edit, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + ) + ) + else: + layout[-1].append( + sg.B( + themepack.quick_edit, + key=keygen.get(f"{key}.quick_edit"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + ) + ) + # return layout + return sg.Col(layout=layout, pad=(0, 0)) + + +def actions( + table: str, + key=None, + default: bool = True, + edit_protect: bool = None, + navigation: bool = None, + insert: bool = None, + delete: bool = None, + duplicate: bool = None, + save: bool = None, + search: bool = None, + search_size: Tuple[int, int] = (30, 1), + bind_return_key: bool = True, + filter: str = None, + use_ttk_buttons: bool = None, + pad=None, + **kwargs, +) -> sg.Column: + """ + Allows for easily adding record navigation and record action elements to the + PySimpleGUI window The navigation elements are generated automatically (first, + previous, next, last and search). The action elements can be customized by + selecting which ones you want generated from the parameters available. This allows + full control over what is available to the user of your database application. Check + out `ThemePacks` to give any of these autogenerated controls a custom look!. + + Note: By default, the base element keys generated for PySimpleGUI will be + `table:action` using the name of the table passed in the table parameter plus the + action strings below separated by a colon: (I.e. Journal:table_insert) edit_protect, + db_save, table_first, table_previous, table_next, table_last, table_duplicate, + table_insert, table_delete, search_input, search_button. If you supply a key with + the key parameter, then these additional strings will be appended to that key. Also + note that these autogenerated keys also pass through the `KeyGen`, so it's possible + that these keys could be table_last:action!1, table_last:action!2, etc. + + :param table: The table name that this "element" will provide actions for + :param key: (optional) The base key to give the generated elements + :param default: Default edit_protect, navigation, insert, delete, save and search to + either true or false (defaults to True) The individual keyword arguments will + trump the default parameter. This allows for starting with all actions + defaulting to False, then individual ones can be enabled with True - or the + opposite by defaulting them all to True, and disabling the ones not needed with + False. + :param edit_protect: An edit protection mode to prevent accidental changes in the + database. It is a button that toggles the ability on and off to prevent + accidental changes in the database by enabling/disabling the insert, edit, + duplicate, delete and save buttons. + :param navigation: The standard << < > >> (First, previous, next, last) buttons for + navigation + :param insert: Button to insert new records + :param delete: Button to delete current record + :param duplicate: Button to duplicate current record + :param save: Button to save record. Note that the save button feature saves changes + made to any table, therefore only one save button is needed per window. + :param search: A search Input element. Size can be specified with the `search_size` + parameter + :param search_size: The size of the search input element + :param bind_return_key: Bind the return key to the search button. Defaults to true. + :param filter: Can be used to reference different `Form`s in the same layout. Use a + matching filter when creating the `Form` with the filter parameter. + :param pad: The padding to use for the generated elements. + :returns: An element to be used in the creation of PySimpleGUI layouts. Note that + this is technically multiple elements wrapped in a PySimpleGUI.Column, but acts + as one element for the purpose of layout building. + """ + + if use_ttk_buttons is None: + use_ttk_buttons = themepack.use_ttk_buttons + if pad is None: + pad = themepack.action_button_pad + + edit_protect = default if edit_protect is None else edit_protect + navigation = default if navigation is None else navigation + insert = default if insert is None else insert + delete = default if delete is None else delete + duplicate = default if duplicate is None else duplicate + save = default if save is None else save + search = default if search is None else search + key = f"{table}:" if key is None else f"{key}:" + + layout = [] + + # Form-level events + if edit_protect: + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_EDIT_PROTECT_DB, + "table": None, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.edit_protect) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}edit_protect"), + size=(1, 1), + image_data=themepack.edit_protect, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.edit_protect, + key=keygen.get(f"{key}edit_protect"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + if save: + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_SAVE_DB, + "table": None, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.save) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}db_save"), + image_data=themepack.save, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B(themepack.save, key=keygen.get(f"{key}db_save"), metadata=meta) + ) + + # DataSet-level events + if navigation: + # first + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_FIRST, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.first) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}table_first"), + size=(1, 1), + image_data=themepack.first, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.first, + key=keygen.get(f"{key}table_first"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + # previous + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_PREVIOUS, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.previous) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}table_previous"), + size=(1, 1), + image_data=themepack.previous, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.previous, + key=keygen.get(f"{key}table_previous"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + # next + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_NEXT, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.next) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}table_next"), + size=(1, 1), + image_data=themepack.next, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.next, + key=keygen.get(f"{key}table_next"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + # last + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_LAST, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.last) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}table_last"), + size=(1, 1), + image_data=themepack.last, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.last, + key=keygen.get(f"{key}table_last"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + if duplicate: + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_DUPLICATE, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.duplicate) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}table_duplicate"), + size=(1, 1), + image_data=themepack.duplicate, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.duplicate, + key=keygen.get(f"{key}table_duplicate"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + if insert: + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_INSERT, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.insert) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}table_insert"), + size=(1, 1), + image_data=themepack.insert, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.insert, + key=keygen.get(f"{key}table_insert"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + if delete: + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_DELETE, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.delete) is bytes: + layout.append( + sg.B( + "", + key=keygen.get(f"{key}table_delete"), + size=(1, 1), + image_data=themepack.delete, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + else: + layout.append( + sg.B( + themepack.delete, + key=keygen.get(f"{key}table_delete"), + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ) + ) + if search: + meta = { + "type": TYPE_EVENT, + "event_type": EVENT_SEARCH, + "table": table, + "column": None, + "function": None, + "Form": None, + "filter": filter, + } + if type(themepack.search) is bytes: + layout += [ + Input("", key=keygen.get(f"{key}search_input"), size=search_size), + sg.B( + "", + key=keygen.get(f"{key}search_button"), + bind_return_key=bind_return_key, + size=(1, 1), + image_data=themepack.search, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ), + ] + else: + layout += [ + sg.Input("", key=keygen.get(f"{key}search_input"), size=search_size), + sg.B( + themepack.search, + key=keygen.get(f"{key}search_button"), + bind_return_key=bind_return_key, + metadata=meta, + use_ttk_buttons=use_ttk_buttons, + pad=pad, + **kwargs, + ), + ] + return sg.Col(layout=[layout], pad=(0, 0)) - def __init__(self, master, init_date, textvariable, **kwargs): - # TODO, set these in themepack? - fwday = kwargs.pop("firstweekday", calendar.MONDAY) - sel_bg = kwargs.pop("selectbackground", "#ecffc4") - sel_fg = kwargs.pop("selectforeground", "#05640e") - super().__init__(master, class_="TtkCalendar", **kwargs) +def selector( + table: str, + element: Type[sg.Element] = sg.LBox, + size: Tuple[int, int] = None, + filter: str = None, + key: str = None, + **kwargs, +) -> sg.Element: + """ + Selectors in pysimplesql are special elements that allow the user to change records + in the database application. For example, Listboxes, Comboboxes and Tables all + provide a convenient way to users to choose which record they want to select. This + convenience function makes creating selectors very quick and as easy as using a + normal PySimpleGUI element. - self.master = master - self.cal_date = init_date - self.textvariable = textvariable - self.cal = calendar.TextCalendar(fwday) - self.font = tkfont.Font(self) - self.header = self.create_header() - self.table = self.create_table() - self.canvas = self.create_canvas(sel_bg, sel_fg) - self.build_calendar() + :param table: The table name in the database that this selector will act on + :param element: The type of element you would like to use as a selector (defaults to + a Listbox) + :param size: The desired size of this selector element + :param filter: Can be used to reference different `Form`s in the same layout. Use a + matching filter when creating the `Form` with the filter parameter. + :param key: (optional) The key to give to this selector. If no key is provided, it + will default to table:selector using the table specified in the table parameter. + This is also passed through the keygen, so if selectors all use the default + name, they will be made unique. ie: Journal:selector!1, Journal:selector!2, etc. + :param kwargs: Any additional arguments supplied will be passed on to the + PySimpleGUI element. + """ + element = Combo if element == sg.Combo else element - def create_header(self): - left_arrow = {"children": [("Button.leftarrow", None)]} - right_arrow = {"children": [("Button.rightarrow", None)]} - style = ttk.Style(self) - style.layout("L.TButton", [("Button.focus", left_arrow)]) - style.layout("R.TButton", [("Button.focus", right_arrow)]) + key = f"{table}:selector" if key is None else key + key = keygen.get(key) - hframe = ttk.Frame(self) - btn_left = ttk.Button( - hframe, style="L.TButton", command=lambda: self.move_month(-1) + meta = {"type": TYPE_SELECTOR, "table": table, "Form": None, "filter": filter} + if element == sg.Listbox: + layout = element( + values=(), + size=size or themepack.default_element_size, + key=key, + select_mode=sg.LISTBOX_SELECT_MODE_SINGLE, + enable_events=True, + metadata=meta, ) - btn_right = ttk.Button( - hframe, style="R.TButton", command=lambda: self.move_month(1) + elif element == sg.Slider: + layout = element( + enable_events=True, + size=size or themepack.default_element_size, + orientation="h", + disable_number_display=True, + key=key, + metadata=meta, ) - label = ttk.Label(hframe, width=15, anchor="center") + elif element == Combo: + w = themepack.default_element_size[0] + layout = element( + values=(), + size=size or (w, 10), + enable_events=True, + key=key, + auto_size_text=False, + metadata=meta, + ) + elif element == sg.Table: + # Check if the headings arg is a Table heading... + if isinstance(kwargs["headings"], TableHeadings): + # Overwrite the kwargs from the TableHeading info + kwargs["visible_column_map"] = kwargs["headings"].visible_map() + kwargs["col_widths"] = kwargs["headings"].width_map() + kwargs["auto_size_columns"] = False # let the col_widths handle it + # Store the TableHeadings object in metadata + # to complete setup on auto_add_elements() + meta["TableHeading"] = kwargs["headings"] + else: + required_kwargs = ["headings", "visible_column_map", "num_rows"] + for kwarg in required_kwargs: + if kwarg not in kwargs: + raise RuntimeError( + f"DataSet selectors must use the {kwarg} keyword argument." + ) - hframe.pack(pady=5, anchor=tk.CENTER) - btn_left.grid(row=0, column=0) - label.grid(row=0, column=1, padx=12) - btn_right.grid(row=0, column=2) - return label + # Create other kwargs that are required + kwargs["enable_events"] = True + kwargs["select_mode"] = sg.TABLE_SELECT_MODE_BROWSE + kwargs["justification"] = "left" - def create_table(self): - cols = self.cal.formatweekheader(3).split() - table = ttk.Treeview(self, show="", selectmode="none", height=7, columns=cols) - table.bind("", self.minsize, "+") - table.pack(expand=1, fill=tk.BOTH) - table.tag_configure("header", background="grey90") - table.insert("", tk.END, values=cols, tag="header") - for _ in range(6): - table.insert("", tk.END) + # Make an empty list of values + vals = [[""] * len(kwargs["headings"])] - width = max(map(self.font.measure, cols)) - for col in cols: - table.column(col, width=width, minwidth=width, anchor=tk.E) - return table + # Create a narrow column for displaying a * character for virtual rows. + # This will be the 1st column + kwargs["visible_column_map"].insert(0, 1) + if "col_widths" in kwargs: + kwargs["col_widths"].insert(0, themepack.unsaved_column_width) - def create_canvas(self, bg, fg): - canvas = tk.Canvas( - self.table, background=bg, borderwidth=1, highlightthickness=0 - ) - canvas.text = canvas.create_text(0, 0, fill=fg, anchor=tk.W) - self.table.bind("", self.pressed_callback, "+") - return canvas + # Change the headings parameter to be a list so + # the heading doesn't display dicts when it first loads + # The TableHeadings instance is already stored in metadata + if isinstance(kwargs["headings"], TableHeadings): + if kwargs["headings"].save_enable: + kwargs["headings"].insert(0, themepack.unsaved_column_header) + else: + kwargs["headings"].insert(0, "") + kwargs["headings"] = kwargs["headings"].heading_names() + else: + kwargs["headings"].insert(0, "") - def build_calendar(self): - year, month = self.cal_date.year, self.cal_date.month - month_name = self.cal.formatmonthname(year, month, 0) - month_weeks = self.cal.monthdayscalendar(year, month) + layout = element(values=vals, key=key, metadata=meta, **kwargs) + else: + raise RuntimeError(f'Element type "{element}" not supported as a selector.') - self.header.config(text=month_name.title()) - items = self.table.get_children()[1:] - for week, item in itertools.zip_longest(month_weeks, items): - fmt_week = [f"{day:02d}" if day else "" for day in (week or [])] - self.table.item(item, values=fmt_week) + return layout - def pressed_callback(self, event): - x, y, widget = event.x, event.y, event.widget - item = widget.identify_row(y) - column = widget.identify_column(x) - items = self.table.get_children()[1:] - if not column or item not in items: - # clicked te header or outside the columns - return +class TableHeadings(list): - index = int(column[1]) - 1 - values = widget.item(item)["values"] - text = values[index] if len(values) else None - bbox = widget.bbox(item, column) - if bbox and text: - self.cal_date = dt.date(self.cal_date.year, self.cal_date.month, int(text)) - self.draw_selection(bbox) - self.textvariable.set(self.cal_date.strftime("%Y-%m-%d")) + """ + This is a convenience class used to build table headings for PySimpleGUI. - def draw_selection(self, bbox): - canvas, text = self.canvas, "%02d" % self.cal_date.day - x, y, width, height = bbox - textw = self.font.measure(text) - canvas.configure(width=width, height=height) - canvas.coords(canvas.text, width - textw, height / 2 - 1) - canvas.itemconfigure(canvas.text, text=text) - canvas.place(x=x, y=y) + In addition, `TableHeading` objects can sort columns in ascending or descending + order by clicking on the column in the heading in the PySimpleGUI Table element if + the sort_enable parameter is set to True. + """ - def set_date(self, dateobj): - self.cal_date = dateobj - self.canvas.place_forget() - self.build_calendar() + # store our instances + instances = [] - def select_date(self): - bbox = self.get_bbox_for_date(self.cal_date) - if bbox: - self.draw_selection(bbox) + def __init__( + self, + sort_enable: bool = True, + edit_enable: bool = False, + save_enable: bool = False, + ) -> None: + """ + Create a new TableHeadings object. - def get_bbox_for_date(self, new_date): - items = self.table.get_children()[1:] - for item in items: - values = self.table.item(item)["values"] - for i, value in enumerate(values): - if isinstance(value, int) and value == new_date.day: - column = "#{}".format(i + 1) - self.table.update() - return self.table.bbox(item, column) - return None + :param sort_enable: True to enable sorting by heading column + :param edit_enable: Enables cell editing if True. Accepted edits update both + `sg.Table` and associated `field` element. + :param save_enable: Enables saving record by double-clicking unsaved marker col. + :returns: None + """ + self.sort_enable = sort_enable + self.edit_enable = edit_enable + self.save_enable = save_enable + self._width_map = [] + self._visible_map = [] + self.readonly_columns = [] - def move_month(self, offset): - self.canvas.place_forget() - month = self.cal_date.month - 1 + offset - year = self.cal_date.year + month // 12 - month = month % 12 + 1 - self.cal_date = dt.date(year, month, 1) - self.build_calendar() + # Store this instance in the master list of instances + TableHeadings.instances.append(self) - def minsize(self, e): - width, height = self.master.geometry().split("x") - height = height[: height.index("+")] - self.master.minsize(width, height) + def add_column( + self, + column: str, + heading_column: str, + width: int, + visible: bool = True, + readonly: bool = False, + ) -> None: + """ + Add a new heading column to this TableHeading object. Columns are added in the + order that this method is called. Note that the primary key column does not need + to be included, as primary keys are stored internally in the `TableRow` class. + :param heading_column: The name of this columns heading (title) + :param column: The name of the column in the database the heading column is for + :param width: The width for this column to display within the Table element + :param visible: True if the column is visible. Typically, the only hidden + column would be the primary key column if any. This is also useful if the + `DataSet.rows` DataFrame has information that you don't want to display. + :param readonly: Indicates if the column is read-only when + `TableHeading.edit_enable` is True. + :returns: None + """ + self.append({"heading": heading_column, "column": column}) + self._width_map.append(width) + self._visible_map.append(visible) + if readonly: + self.readonly_columns.append(column) -class DatePicker(ttk.Entry): - def __init__(self, master, frm_reference, init_date, **kwargs): - self.frm = frm_reference - textvariable = kwargs["textvariable"] - self.calendar = TtkCalendar(self.frm.window.TKroot, init_date, textvariable) - self.calendar.place_forget() - self.button = ttk.Button(master, text="▼", width=2, command=self.show_calendar) - super().__init__(master, **kwargs) + def heading_names(self) -> List[str]: + """ + Return a list of heading_names for use with the headings parameter of + PySimpleGUI.Table. - self.bind("", self.on_entry_key_release, "+") - self.calendar.bind("", self.hide_calendar, "+") + :returns: a list of heading names + """ + return [c["heading"] for c in self] - def show_calendar(self, event=None): - self.configure(state="disabled") - self.calendar.place(in_=self, relx=0, rely=1) - self.calendar.focus_force() - self.calendar.select_date() + def columns(self): + """ + Return a list of column names. - def hide_calendar(self, event=None): - self.configure(state="!disabled") - self.calendar.place_forget() - self.focus_force() + :returns: a list of column names + """ + return [c["column"] for c in self if c["column"] is not None] - def on_entry_key_release(self, event=None): - # Check if the user has typed a valid date - try: - date_str = self.get() - date = dt.datetime.strptime(date_str, "%Y-%m-%d") - except ValueError: - return + def visible_map(self) -> List[Union[bool, int]]: + """ + Convenience method for creating PySimpleGUI tables. - # Update the calendar to show the new date - self.calendar.set_date(date) + :returns: a list of visible columns for use with th PySimpleGUI Table + visible_column_map parameter + """ + return list(self._visible_map) + def width_map(self) -> List[int]: + """ + Convenience method for creating PySimpleGUI tables. -class _LiveUpdate: + :returns: a list column widths for use with th PySimpleGUI Table col_widths + parameter + """ + return list(self._width_map) - """Internal class used to automatically sync selectors with field changes""" + def update_headings( + self, element: sg.Table, sort_column=None, sort_order: int = None + ) -> None: + """ + Perform the actual update to the PySimpleGUI Table heading. + Note: Not typically called by the end user. - def __init__(self, frm_reference: Form): - self.frm = frm_reference - self.last_event_widget = None - self.last_event_time = None - self.delay_seconds = 0.25 + :param element: The PySimpleGUI Table element + :param sort_column: The column to show the sort direction indicators on + :param sort_order: A SORT_* constant (SORT_NONE, SORT_ASC, SORT_DESC) + :returns: None + """ - def __call__(self, event): - # keep track of time on same widget - if event.widget == self.last_event_widget: - self.last_event_time = time() - self.last_event_widget = event.widget + # Load in our marker characters. We will use them to both display the + # sort direction and to detect current direction + try: + asc = themepack.marker_sort_asc + except AttributeError: + asc = "\u25BC" + try: + desc = themepack.marker_sort_desc + except AttributeError: + desc = "\u25B2" - # get widget type - widget_type = event.widget.__class__.__name__ + for i, x in zip(range(len(self)), self): + # Clear the direction markers + x["heading"] = x["heading"].replace(asc, "").replace(desc, "") + if ( + x["column"] == sort_column + and sort_column is not None + and sort_order != SORT_NONE + ): + x["heading"] += asc if sort_order == SORT_ASC else desc + element.Widget.heading(i, text=x["heading"], anchor="w") - # if <> and a combobox, or a checkbutton - if ( - event.type == "35" and widget_type == "Combobox" - ) or widget_type == "Checkbutton": - self.sync(event.widget, widget_type) + def enable_heading_function(self, element: sg.Table, fn: callable) -> None: + """ + Enable the sorting callbacks for each column index, or saving by click the + unsaved changes column + Note: Not typically used by the end user. Called from `Form.auto_map_elements()` - # use tk.after() for text, so waits for pause in typing to update selector. - elif widget_type in ["Entry", "Text"]: - self.frm.window.TKroot.after( - int(self.delay_seconds * 1000), - lambda: self.delay(event.widget, widget_type), - ) + :param element: The PySimpleGUI Table element associated with this TableHeading + :param fn: A callback functions to run when a heading is clicked. The callback + should take one column parameter. + :returns: None + """ + if self.sort_enable: + for i in range(len(self)): + if self[i]["column"] is not None: + element.widget.heading( + i, command=functools.partial(fn, self[i]["column"], False) + ) + self.update_headings(element) + if self.save_enable: + element.widget.heading(0, command=functools.partial(fn, None, save=True)) - def sync(self, widget, widget_type): - for e in self.frm.element_map: - if e["element"].widget == widget: - data_key = e["table"] - column = e["column"] - element = e["element"] - if widget_type == TK_COMBOBOX and isinstance(element.get(), ElementRow): - new_value = element.get().get_pk() - else: - new_value = element.get() + def insert(self, idx, heading_column: str, column: str = None, *args, **kwargs): + super().insert(idx, {"heading": heading_column, "column": column}) - dataset = self.frm[data_key] - # get cast new value to correct type - for col in dataset.column_info: - if col["name"] == column: - new_value = col.cast(new_value) - break +class _HeadingCallback: - # see if there was a change - old_value = dataset.get_current_row()[column] - new_value = dataset.value_changed( - column, old_value, new_value, bool(widget_type == TK_CHECKBUTTON) - ) - if new_value is not Boolean.FALSE: - # push row to dataset and update - dataset.set_current(column, new_value, write_event=True) + """Internal class used when sg.Table column headings are clicked.""" - # Update tableview if uses column: - if dataset.column_likely_in_selector(column): - self.frm.update_selectors(dataset.key) + def __init__(self, frm_reference: Form, data_key: str): + """ + Create a new _HeadingCallback object. - def delay(self, widget, widget_type): - if self.last_event_time: - elapsed_sec = time() - self.last_event_time - if elapsed_sec >= self.delay_seconds: - self.sync(widget, widget_type) + :param frm_reference: `Form` object + :param data_key: `DataSet` key + :returns: None + """ + self.frm: Form = frm_reference + self.data_key = data_key + + def __call__(self, column, save): + if save: + self.frm[self.data_key].save_record() + # force a timeout, without this + # info popup creation broke pysimplegui events, weird! + self.frm.window.read(timeout=1) else: - self.sync(widget, widget_type) + self.frm[self.data_key].sort_cycle( + column, self.data_key, update_elements=True + ) # ====================================================================================== From 32692c3203b285a6e85f5d06839bc7fd61f6a13c Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 24 May 2023 11:43:22 -0400 Subject: [PATCH 51/66] Update .git-blame-ignore-revs --- .git-blame-ignore-revs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 5fed50b9..1a0c37e4 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -17,4 +17,8 @@ e4787f7ae96e2a6f11838dcd5591b58ab69fc55a cb040bf0656ab6b3c019fadc6adf98c7d4ba01ea f7addad546672815db9293f772db71c57521f8e8 fbeec4c4322b7a1f8dc4cd82ac3c10e6c313901a +# move langformat +7a3d17b934d739118fb074f0f572d1e30651117d +# moving widgets under Widgets dummy class +e94b09e4f103dfade9d6fffc095507b58c836bce From f679c03bb4da65a5d98dd7eb04c6cb2d1120d289 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 24 May 2023 12:25:58 -0400 Subject: [PATCH 52/66] Ignore case for combo autocompletion --- pysimplesql/pysimplesql.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 1f2c1f5b..f4a99ff6 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5174,13 +5174,15 @@ def _autocomplete_combo(widget, completion_list, delta=0): # Set the position to the length of the current input text widget.position = len(widget.get()) - prefix = widget.get() - hits = [element for element in completion_list if element.startswith(prefix)] - # Create a list of elements that start with the prefix + prefix = widget.get().lower() + hits = [ + element for element in completion_list if element.lower().startswith(prefix) + ] + # Create a list of elements that start with the lowercase prefix if hits: closest_match = min(hits, key=len) - if prefix != closest_match: + if prefix != closest_match.lower(): # Insert the closest match at the beginning, move the cursor to the end widget.delete(0, tk.END) widget.insert(0, closest_match) @@ -5189,8 +5191,8 @@ def _autocomplete_combo(widget, completion_list, delta=0): # Highlight the remaining text after the closest match widget.select_range(widget.position, tk.END) - if len(hits) == 1 and closest_match != prefix: - # If there is only one hit and it's not equal to the prefix, open dropdown + if len(hits) == 1 and closest_match.lower() != prefix: + # If there is only one hit and it's not equal to the lowercase prefix, open dropdown widget.event_generate("") widget.event_generate("<>") From e10c2ce2d01437f675c0a305fc30ebbaca0b3b7a Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 24 May 2023 14:22:30 -0400 Subject: [PATCH 53/66] Rewritten Input placeholder This better matches modern placeholders, where it only disappears when a user types something, and immediately reappears when input is empty --- pysimplesql/pysimplesql.py | 69 +++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index f4a99ff6..ce4f9dcf 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5016,6 +5016,12 @@ def __init__(self, *args, **kwargs): self.placeholder_color = None self.placeholder_font = None self.active_placeholder = True + # fmt: off + self._non_keys = ["Control_L","Control_R","Alt_L","Alt_R","Shift_L","Shift_R", + "Caps_Lock","Return","Escape","Tab","BackSpace","Up","Down","Left", + "Right","Home","End","Page_Up","Page_Down","F1","F2","F3","F4","F5", + "F6","F7","F8","F9","F10","F11","F12", "Delete"] + # fmt: on def add_placeholder(self, placeholder: str, color: str = None, font: str = None): """ @@ -5072,12 +5078,12 @@ def update(self, *args, **kwargs): # Otherwise, use the current value value = self.get() - if self.active_placeholder and value != "": # noqa PLC1901 + if self.active_placeholder and value: # Replace the placeholder with the new value super().update(value=value) self.active_placeholder = False self.Widget.config(fg=self.normal_color, font=self.normal_font) - elif value == "": # noqa PLC1901 + elif not value: # If the value is empty, reinsert the placeholder super().update(value=self.placeholder_text, *args, **kwargs) self.active_placeholder = True @@ -5108,31 +5114,63 @@ def __init__(self, *args, **kwargs): def _add_binds(self): widget = self.widget - def on_focusin(event): - if self.active_placeholder: + def on_key(event): + if self.active_placeholder and widget.get() == self.placeholder_text: + if event.keysym in self._non_keys and widget.index(tk.INSERT) in [0, 1]: + return + # Clear the placeholder when the user starts typing widget.delete(0, "end") widget.config(fg=self.normal_color, font=self.normal_font) - self.active_placeholder = False - def on_focusout(event): - if widget.get() == "": # noqa PLC1901 - widget.insert(0, self.placeholder_text) - widget.config(fg=self.placeholder_color, font=self.placeholder_font) + elif ( + (not self.active_placeholder and not widget.get()) + or (event.keysym == "BackSpace" and len(widget.get()) == 1) + or ( + event.keysym in ["BackSpace", "Delete"] + and widget.select_present() + and widget.selection_get() == widget.get() + ) + ): + with contextlib.suppress(tk.TclError): + enable_placeholder() + widget.icursor(0) - self.active_placeholder = True + def on_focusin(event): + if self.active_placeholder: + # Move cursor to the beginning if the field has a placeholder + widget.icursor(0) - if widget.get() == "" and self.active_placeholder: # noqa PLC1901 + def on_focusout(event): + if not widget.get(): + enable_placeholder() + + def enable_placeholder(): + widget.delete(0, "end") widget.insert(0, self.placeholder_text) widget.config(fg=self.placeholder_color, font=self.placeholder_font) + self.active_placeholder = True + def disable_placeholder_select(event): + # Disable selecting the placeholder + if self.active_placeholder: + return "break" + return None + + widget.bind("", on_key, "+") widget.bind("", on_focusin, "+") widget.bind("", on_focusout, "+") + widget.bind("<>", disable_placeholder_select, "+") + widget.bind("", disable_placeholder_select, "+") + widget.bind("", disable_placeholder_select, "+") + + if not widget.get(): + enable_placeholder() class Multiline(_PlaceholderText, sg.Multiline): """ - A Multiline that allows for the display of a placeholder text when empty. + A Multiline that allows for the display of a placeholder text when focus-out empty. """ def __init__(self, *args, **kwargs): @@ -5155,9 +5193,7 @@ def on_focusout(event): self.active_placeholder = True - if ( - not widget.get("1.0", "end-1c").strip() and self.active_placeholder - ): # noqa PLC1901 + if not widget.get("1.0", "end-1c").strip() and self.active_placeholder: widget.insert("1.0", self.placeholder_text) widget.config(fg=self.placeholder_color, font=self.placeholder_font) @@ -5192,7 +5228,8 @@ def _autocomplete_combo(widget, completion_list, delta=0): widget.select_range(widget.position, tk.END) if len(hits) == 1 and closest_match.lower() != prefix: - # If there is only one hit and it's not equal to the lowercase prefix, open dropdown + # If there is only one hit and it's not equal to the lowercase prefix, + # open dropdown widget.event_generate("") widget.event_generate("<>") From 9a358e6de8a61eee3b463c8e0892faafffe7388c Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 24 May 2023 15:12:09 -0400 Subject: [PATCH 54/66] Commenting and added graceful handing of user calling add_placeholder again --- pysimplesql/pysimplesql.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index ce4f9dcf..e483a376 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5109,13 +5109,20 @@ class Input(_PlaceholderText, sg.Input): """ def __init__(self, *args, **kwargs): + self.binds = {} super().__init__(*args, **kwargs) def _add_binds(self): widget = self.widget + if self.binds: + # remove any existing binds + for event, funcid in self.binds.items(): + self.widget.unbind(event, funcid) + self.binds = {} def on_key(event): if self.active_placeholder and widget.get() == self.placeholder_text: + # dont clear for non-text-producing keys if event.keysym in self._non_keys and widget.index(tk.INSERT) in [0, 1]: return # Clear the placeholder when the user starts typing @@ -5123,6 +5130,10 @@ def on_key(event): widget.config(fg=self.normal_color, font=self.normal_font) self.active_placeholder = False + # insert placeholder when: + # 1) widget is empty + # 2) user hits backspace and only 1 character left + # 3) or they have selected all their text and pressed backspace/delete elif ( (not self.active_placeholder and not widget.get()) or (event.keysym == "BackSpace" and len(widget.get()) == 1) @@ -5157,12 +5168,11 @@ def disable_placeholder_select(event): return "break" return None - widget.bind("", on_key, "+") - widget.bind("", on_focusin, "+") - widget.bind("", on_focusout, "+") - widget.bind("<>", disable_placeholder_select, "+") - widget.bind("", disable_placeholder_select, "+") - widget.bind("", disable_placeholder_select, "+") + self.binds[""] = widget.bind("", on_key, "+") + self.binds[""] = widget.bind("", on_focusin, "+") + self.binds[""] = widget.bind("", on_focusout, "+") + for event in ["<>", "", ""]: + self.binds[event] = widget.bind(event, disable_placeholder_select, "+") if not widget.get(): enable_placeholder() @@ -5174,10 +5184,15 @@ class Multiline(_PlaceholderText, sg.Multiline): """ def __init__(self, *args, **kwargs): + self.binds = {} super().__init__(*args, **kwargs) def _add_binds(self): widget = self.widget + if self.binds: + for event, bind in self.binds.items(): + self.widget.unbind(event, bind) + self.binds = {} def on_focusin(event): if self.active_placeholder: @@ -5197,8 +5212,8 @@ def on_focusout(event): widget.insert("1.0", self.placeholder_text) widget.config(fg=self.placeholder_color, font=self.placeholder_font) - widget.bind("", on_focusin, "+") - widget.bind("", on_focusout, "+") + self.binds[""] = widget.bind("", on_focusin, "+") + self.binds[""] = widget.bind("", on_focusout, "+") def _autocomplete_combo(widget, completion_list, delta=0): From 7b6c8c4a2c75e893ea8c6c766c2c24dc42902ce8 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 25 May 2023 16:59:49 -0400 Subject: [PATCH 55/66] LazyTable The LazyTable is a subclass of sg.Table for improved performance by loading rows lazily during scroll events. Updating a sg.Table is generally fast, but with large DataSets that contain thousands of rows, there may be some noticeable lag. LazyTable overcomes this by only inserting only visible rows during an `update()` call. To use it, provide values in the form of [TableRow(pk, values)], finalize the sg.Window, and call update(). Please note that LazyTable does not support alternating_row_color or row_colors. --- pysimplesql/pysimplesql.py | 221 ++++++++++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 5 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index e483a376..2869c94e 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -4293,9 +4293,11 @@ def process_events(self, event: str, values: list) -> bool: dataset.set_by_pk(row.get_pk()) changed = True elif isinstance(element, sg.Table) and len(values[event]): - index = values[event][0] - pk = self.window[event].Values[index].pk - + if isinstance(element, LazyTable): + pk = int(values[event]) + else: + index = values[event][0] + pk = self.window[event].Values[index].pk # no need to update the selector! dataset.set_by_pk(pk, True, omit_elements=[element]) @@ -4442,7 +4444,7 @@ def update_table_element( element.update(values=values, select_rows=select_rows) # make sure row_iid is visible - if len(values): + if not isinstance(element, LazyTable) and len(values): row_iid = element.tree_ids[select_rows[0]] element.widget.see(row_iid) @@ -5002,6 +5004,215 @@ class Widgets: pass +class LazyTable(sg.Table): + + """ + The LazyTable is a subclass of sg.Table for improved performance by loading rows + lazily during scroll events. Updating a sg.Table is generally fast, but with large + DataSets that contain thousands of rows, there may be some noticeable lag. LazyTable + overcomes this by only inserting only visible rows during an `update()` call. + + To use it, provide values in the form of [TableRow(pk, values)], finalize the + sg.Window, and call update(). Please note that LazyTable does not support + alternating_row_color or row_colors. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.values = [] # full set of rows + self.data = [] # lazy slice of rows + self.Values = self.data + + self.init_insert_qty = self.NumRows + """Number of rows to insert initially during an `update()`""" + + self.scroll_insert_qty = 2 + """Number of rows to insert during a scroll event""" + + self._start_index = 0 + self._end_index = 0 + self._finalized = False + self._lock = threading.Lock() + + def update(self, values=None, num_rows=None, visible=None, select_rows=None): + self.values = values + # Update current_index with the selected index + self.current_index = select_rows[0] if select_rows else 0 + + if not self._widget_was_created(): + return + + if not self._finalized: + self.widget.configure(yscrollcommand=self._handle_scroll) + self._finalized = True + + if self._this_elements_window_closed(): + return + + for iid in self.tree_ids: + self.TKTreeview.item(iid, tags=()) + if ( + self.BackgroundColor is not None + and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT + ): + self.TKTreeview.tag_configure(iid, background=self.BackgroundColor) + else: + self.TKTreeview.tag_configure( + iid, background="#FFFFFF", foreground="#000000" + ) + if self.TextColor is not None and self.TextColor != sg.COLOR_SYSTEM_DEFAULT: + self.TKTreeview.tag_configure(iid, foreground=self.TextColor) + else: + self.TKTreeview.tag_configure(iid, foreground="#000000") + + children = self.TKTreeview.get_children() + for i in children: + self.TKTreeview.detach(i) + self.TKTreeview.delete(i) + + self.tree_ids = [] + if select_rows is not None: + # Slice the list to show visible rows before and after the current index + self._start_index = max(0, self.current_index - self.init_insert_qty) + self._end_index = min( + len(values), self.current_index + self.init_insert_qty + 1 + ) + self.data = values[self._start_index : self._end_index] + + # insert the rows + for row in self.data: + iid = self.TKTreeview.insert( + "", "end", text=row, iid=row.pk, values=row + ) + if ( + self.BackgroundColor is not None + and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT + ): + self.TKTreeview.tag_configure(iid, background=self.BackgroundColor) + else: + self.TKTreeview.tag_configure(iid, background="#FFFFFF") + self.tree_ids.append(iid) + + if visible is not None: + self._visible = visible + if visible is False: + self._pack_forget_save_settings(self.element_frame) + elif visible is True: + self._pack_restore_settings(self.element_frame) + + if num_rows is not None: + self.TKTreeview.config(height=num_rows) + + if select_rows is not None: + # Scroll to the selected row if it exists + # Offset select_rows index for the sliced values + offset_select_rows = [i - self._start_index for i in select_rows] + if offset_select_rows and offset_select_rows[0] < len(self.data): + self.widget.selection_set(self.tree_ids[offset_select_rows[0]]) + # Get the row iid based on the offset_select_rows index + row_iid = self.tree_ids[offset_select_rows[0]] + self.widget.see(row_iid) + + def _handle_scroll(self, x0, x1): + if float(x0) == 0.0: + with self._lock: + # Check if it's possible to insert more rows before this record + if self._start_index > 0: + # Insert more rows before the current record + + # Number of additional rows to retrieve + num_rows = min(self._start_index, self.scroll_insert_qty) + # Index to start retrieving additional rows + new_start_index = max(0, self._start_index - num_rows) + + new_rows = self.values[new_start_index : self._start_index] + + # Insert new rows into the Treeview in reverse order + for row in reversed(new_rows): + iid = self.TKTreeview.insert( + "", "0", text=row, iid=row.pk, values=row + ) + if ( + self.BackgroundColor is not None + and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT + ): + self.TKTreeview.tag_configure( + iid, background=self.BackgroundColor + ) + else: + self.TKTreeview.tag_configure(iid, background="#FFFFFF") + # Insert the new iid at the beginning of tree_ids + self.tree_ids.insert(0, iid) + self._start_index = new_start_index # Update the current_index + self.data[:0] = new_rows # Prepend new_rows to self.data + + # to avoid an infinite scroll, move scroll a little before 0.0 + # by `see`ing 1 row down. + with contextlib.suppress(IndexError): + row_iid = self.tree_ids[self.NumRows + 1] + self.widget.see(row_iid) + return + + self.vsb.set(x0, x1) + + if float(x1) > 0.9: + with self._lock: + num_rows = len( + self.values + ) # Assuming values is the complete list of data + if self._end_index < num_rows: + start_index = max( + 0, self._end_index + ) # Index to start retrieving additional rows + end_index = min( + self._end_index + self.scroll_insert_qty, num_rows + ) # Number of additional rows to retrieve + new_rows = self.values[start_index:end_index] + + # Insert new rows into the Treeview + for row in new_rows: + iid = self.widget.insert( + "", "end", text=row, iid=row.pk, values=row + ) + self.tree_ids.append(iid) # Append the new iid to tree_ids + + self._end_index = end_index + self.data.extend(new_rows) # Extend self.data with new_rows + + @property + def SelectedRows(self): + """ + Returns the selected row(s) in the LazyTable. + + :returns: + - If the LazyTable has data: + - Retrieves the index of the selected row by matching the primary key + (pk) value with the first selected item in the TKTreeview. + - Returns the corresponding row from the data list based on the index. + - If the LazyTable has no data: + - Returns None. + + :note: + This property assumes that the LazyTable is using a primary key (pk) value + to uniquely identify rows in the data list. + """ + if self.data: + index = [ + [v.pk for v in self.data].index( + [int(x) for x in self.TKTreeview.selection()][0] + ) + ][0] + return self.data[index] + return None + + def __setattr__(self, name, value): + if name == "SelectedRows": + # Custom handling for 'SelectedRows' attribute assignment + # Example: Prevent assignment of non-list values + return + super().__setattr__(name, value) + + class _PlaceholderText(abc.ABC): """ An abstract class for PySimpleGUI text-entry elements that allows for the display of @@ -6631,7 +6842,7 @@ def selector( auto_size_text=False, metadata=meta, ) - elif element == sg.Table: + elif element in [sg.Table, LazyTable]: # Check if the headings arg is a Table heading... if isinstance(kwargs["headings"], TableHeadings): # Overwrite the kwargs from the TableHeading info From d1b03268eda1caa32c0cece070135dcdce360f17 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 26 May 2023 09:53:04 -0400 Subject: [PATCH 56/66] Refactor and add alternating_row_color support to LazyTable --- pysimplesql/pysimplesql.py | 210 +++++++++++++++++++++++-------------- 1 file changed, 129 insertions(+), 81 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 2869c94e..79296fbc 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1450,7 +1450,11 @@ def search( # grab the first result pk = result.iloc[0][self.pk_column] if pk == self[self.pk_column]: - return None + if update_elements: + self.frm.update_elements(self.key) + if requery_dependents: + self.requery_dependents() + return SEARCH_RETURNED self.set_by_pk( pk=pk, update_elements=update_elements, @@ -5010,11 +5014,11 @@ class LazyTable(sg.Table): The LazyTable is a subclass of sg.Table for improved performance by loading rows lazily during scroll events. Updating a sg.Table is generally fast, but with large DataSets that contain thousands of rows, there may be some noticeable lag. LazyTable - overcomes this by only inserting only visible rows during an `update()` call. + overcomes this by only inserting a slice of rows during an `update()`. To use it, provide values in the form of [TableRow(pk, values)], finalize the - sg.Window, and call update(). Please note that LazyTable does not support - alternating_row_color or row_colors. + sg.Window, and call update(). Please note that LazyTable does not support the + `sg.Table` `row_colors` argument. """ def __init__(self, *args, **kwargs): @@ -5023,18 +5027,24 @@ def __init__(self, *args, **kwargs): self.data = [] # lazy slice of rows self.Values = self.data - self.init_insert_qty = self.NumRows - """Number of rows to insert initially during an `update()`""" - - self.scroll_insert_qty = 2 - """Number of rows to insert during a scroll event""" + self.insert_qty = max(self.NumRows, 50) + """Number of rows to insert during an `update(values=)` and scroll events""" self._start_index = 0 self._end_index = 0 + self._start_alt_color = False + self._end_alt_color = False self._finalized = False self._lock = threading.Lock() - def update(self, values=None, num_rows=None, visible=None, select_rows=None): + def update( + self, + values=None, + num_rows=None, + visible=None, + select_rows=None, + alternating_row_color=None, + ): self.values = values # Update current_index with the selected index self.current_index = select_rows[0] if select_rows else 0 @@ -5049,6 +5059,11 @@ def update(self, values=None, num_rows=None, visible=None, select_rows=None): if self._this_elements_window_closed(): return + if alternating_row_color is not None: + self.AlternatingRowColor = alternating_row_color + self._start_alt_color = True + + # delete all current for iid in self.tree_ids: self.TKTreeview.item(iid, tags=()) if ( @@ -5069,21 +5084,25 @@ def update(self, values=None, num_rows=None, visible=None, select_rows=None): for i in children: self.TKTreeview.detach(i) self.TKTreeview.delete(i) - self.tree_ids = [] + + # get values to insert if select_rows is not None: # Slice the list to show visible rows before and after the current index - self._start_index = max(0, self.current_index - self.init_insert_qty) - self._end_index = min( - len(values), self.current_index + self.init_insert_qty + 1 - ) + self._start_index = max(0, self.current_index - self.insert_qty) + self._end_index = min(len(values), self.current_index + self.insert_qty + 1) self.data = values[self._start_index : self._end_index] + else: + self.data = values + # insert values + if values is not None: # insert the rows for row in self.data: iid = self.TKTreeview.insert( - "", "end", text=row, iid=row.pk, values=row + "", "end", text=row, iid=row.pk, values=row, tag=row.pk ) + # set background if ( self.BackgroundColor is not None and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT @@ -5091,93 +5110,122 @@ def update(self, values=None, num_rows=None, visible=None, select_rows=None): self.TKTreeview.tag_configure(iid, background=self.BackgroundColor) else: self.TKTreeview.tag_configure(iid, background="#FFFFFF") + # override with alt + if self.AlternatingRowColor is not None: + if not self._end_alt_color: + self.TKTreeview.tag_configure( + row.pk, background=self.AlternatingRowColor + ) + self._end_alt_color = not self._end_alt_color + # add to tree_ids self.tree_ids.append(iid) + # handle visible if visible is not None: self._visible = visible - if visible is False: - self._pack_forget_save_settings(self.element_frame) - elif visible is True: - self._pack_restore_settings(self.element_frame) + if visible: + self._pack_restore_settings(self.element_frame) + else: + self._pack_forget_save_settings(self.element_frame) + # handle number of rows if num_rows is not None: self.TKTreeview.config(height=num_rows) + # finally, select rows and make first visible if select_rows is not None: - # Scroll to the selected row if it exists # Offset select_rows index for the sliced values offset_select_rows = [i - self._start_index for i in select_rows] if offset_select_rows and offset_select_rows[0] < len(self.data): + # select the row self.widget.selection_set(self.tree_ids[offset_select_rows[0]]) # Get the row iid based on the offset_select_rows index row_iid = self.tree_ids[offset_select_rows[0]] + # and make sure its visible self.widget.see(row_iid) def _handle_scroll(self, x0, x1): if float(x0) == 0.0: - with self._lock: - # Check if it's possible to insert more rows before this record - if self._start_index > 0: - # Insert more rows before the current record - - # Number of additional rows to retrieve - num_rows = min(self._start_index, self.scroll_insert_qty) - # Index to start retrieving additional rows - new_start_index = max(0, self._start_index - num_rows) - - new_rows = self.values[new_start_index : self._start_index] - - # Insert new rows into the Treeview in reverse order - for row in reversed(new_rows): - iid = self.TKTreeview.insert( - "", "0", text=row, iid=row.pk, values=row - ) - if ( - self.BackgroundColor is not None - and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT - ): - self.TKTreeview.tag_configure( - iid, background=self.BackgroundColor - ) - else: - self.TKTreeview.tag_configure(iid, background="#FFFFFF") - # Insert the new iid at the beginning of tree_ids - self.tree_ids.insert(0, iid) - self._start_index = new_start_index # Update the current_index - self.data[:0] = new_rows # Prepend new_rows to self.data - - # to avoid an infinite scroll, move scroll a little before 0.0 - # by `see`ing 1 row down. - with contextlib.suppress(IndexError): - row_iid = self.tree_ids[self.NumRows + 1] - self.widget.see(row_iid) - return - + self._handle_start_scroll() + if float(x1) == 1.0: + self._handle_end_scroll() + # else, set the scroll self.vsb.set(x0, x1) - if float(x1) > 0.9: - with self._lock: - num_rows = len( - self.values - ) # Assuming values is the complete list of data - if self._end_index < num_rows: - start_index = max( - 0, self._end_index - ) # Index to start retrieving additional rows - end_index = min( - self._end_index + self.scroll_insert_qty, num_rows - ) # Number of additional rows to retrieve - new_rows = self.values[start_index:end_index] - - # Insert new rows into the Treeview - for row in new_rows: - iid = self.widget.insert( - "", "end", text=row, iid=row.pk, values=row - ) - self.tree_ids.append(iid) # Append the new iid to tree_ids + def _handle_start_scroll(self): + with self._lock: + if self._start_index > 0: + # determine slice + num_rows = min(self._start_index, self.insert_qty) + new_start_index = max(0, self._start_index - num_rows) + new_rows = self.values[new_start_index : self._start_index] + + # insert + for row in reversed(new_rows): + iid = self.TKTreeview.insert( + "", "0", text=row, iid=row.pk, values=row, tag=row.pk + ) + self._start_alt_color = self._set_background( + iid, self._start_alt_color + ) + self.tree_ids.insert(0, iid) + + # set new start + self._start_index = new_start_index + + # Insert new_rows to beginning + # don't use data.insert(0, new_rows), it breaks TableRow + self.data[:0] = new_rows + + # to avoid an infinite scroll, move scroll a little after 0.0 + with contextlib.suppress(IndexError): + row_iid = self.tree_ids[self.insert_qty + self.NumRows - 1] + self.widget.see(row_iid) + + def _handle_end_scroll(self): + with self._lock: + num_rows = len(self.values) + if self._end_index < num_rows: + # determine slice + start_index = max(0, self._end_index) + end_index = min(self._end_index + self.insert_qty, num_rows) + new_rows = self.values[start_index:end_index] + + # insert + for row in new_rows: + iid = self.widget.insert( + "", "end", text=row, iid=row.pk, values=row, tag=row.pk + ) + self._end_alt_color = self._set_background(iid, self._end_alt_color) + self.tree_ids.append(iid) - self._end_index = end_index - self.data.extend(new_rows) # Extend self.data with new_rows + # set new end + self._end_index = end_index + + # Extend self.data with new_rows + self.data.extend(new_rows) + + # to avoid an infinite scroll, move scroll a little before 1.0 + with contextlib.suppress(IndexError): + row_iid = self.tree_ids[len(self.data) - self.insert_qty] + self.widget.see(row_iid) + + def _set_background(self, iid, toggle_color): + # first set background + if ( + self.BackgroundColor is not None + and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT + ): + self.TKTreeview.tag_configure(iid, background=self.BackgroundColor) + else: + self.TKTreeview.tag_configure(iid, background="#FFFFFF") + + # then override with alt color if set + if self.AlternatingRowColor is not None: + if not toggle_color: + self.TKTreeview.tag_configure(iid, background=self.AlternatingRowColor) + toggle_color = not toggle_color + return toggle_color @property def SelectedRows(self): From da3e2588a6a3e553698450bc4baed031dd71671a Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 26 May 2023 10:45:59 -0400 Subject: [PATCH 57/66] small fix --- pysimplesql/pysimplesql.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 79296fbc..db31c733 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5027,7 +5027,7 @@ def __init__(self, *args, **kwargs): self.data = [] # lazy slice of rows self.Values = self.data - self.insert_qty = max(self.NumRows, 50) + self.insert_qty = max(self.NumRows, 25) """Number of rows to insert during an `update(values=)` and scroll events""" self._start_index = 0 @@ -5147,8 +5147,10 @@ def update( def _handle_scroll(self, x0, x1): if float(x0) == 0.0: self._handle_start_scroll() + return if float(x1) == 1.0: self._handle_end_scroll() + return # else, set the scroll self.vsb.set(x0, x1) From 16d722042a4aea96ea15fec783afbac819443ab6 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 26 May 2023 13:15:55 -0400 Subject: [PATCH 58/66] More LazyTable cleanup --- pysimplesql/pysimplesql.py | 185 +++++++++++++++++-------------------- 1 file changed, 84 insertions(+), 101 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index db31c733..709ff812 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5036,6 +5036,8 @@ def __init__(self, *args, **kwargs): self._end_alt_color = False self._finalized = False self._lock = threading.Lock() + self._bg = None + self._fg = None def update( self, @@ -5049,9 +5051,25 @@ def update( # Update current_index with the selected index self.current_index = select_rows[0] if select_rows else 0 + # determine bg + self._bg = ( + self.BackgroundColor + if self.BackgroundColor is not None + and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT + else "#FFFFFF" + ) + + # determine fg + self._fg = ( + self.TextColor + if self.TextColor is not None and self.TextColor != sg.COLOR_SYSTEM_DEFAULT + else "#000000" + ) + if not self._widget_was_created(): return + # needed, since PySimpleGUI doesn't create tk widgets during class init if not self._finalized: self.widget.configure(yscrollcommand=self._handle_scroll) self._finalized = True @@ -5063,27 +5081,15 @@ def update( self.AlternatingRowColor = alternating_row_color self._start_alt_color = True - # delete all current + # remove tags # TODO: this may not be necessary for iid in self.tree_ids: self.TKTreeview.item(iid, tags=()) - if ( - self.BackgroundColor is not None - and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT - ): - self.TKTreeview.tag_configure(iid, background=self.BackgroundColor) - else: - self.TKTreeview.tag_configure( - iid, background="#FFFFFF", foreground="#000000" - ) - if self.TextColor is not None and self.TextColor != sg.COLOR_SYSTEM_DEFAULT: - self.TKTreeview.tag_configure(iid, foreground=self.TextColor) - else: - self.TKTreeview.tag_configure(iid, foreground="#000000") - children = self.TKTreeview.get_children() + # delete all current + children = self.widget.get_children() for i in children: - self.TKTreeview.detach(i) - self.TKTreeview.delete(i) + self.widget.detach(i) + self.widget.delete(i) self.tree_ids = [] # get values to insert @@ -5099,25 +5105,10 @@ def update( if values is not None: # insert the rows for row in self.data: - iid = self.TKTreeview.insert( + iid = self.widget.insert( "", "end", text=row, iid=row.pk, values=row, tag=row.pk ) - # set background - if ( - self.BackgroundColor is not None - and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT - ): - self.TKTreeview.tag_configure(iid, background=self.BackgroundColor) - else: - self.TKTreeview.tag_configure(iid, background="#FFFFFF") - # override with alt - if self.AlternatingRowColor is not None: - if not self._end_alt_color: - self.TKTreeview.tag_configure( - row.pk, background=self.AlternatingRowColor - ) - self._end_alt_color = not self._end_alt_color - # add to tree_ids + self._end_alt_color = self._set_colors(iid, self._end_alt_color) self.tree_ids.append(iid) # handle visible @@ -5130,7 +5121,7 @@ def update( # handle number of rows if num_rows is not None: - self.TKTreeview.config(height=num_rows) + self.widget.config(height=num_rows) # finally, select rows and make first visible if select_rows is not None: @@ -5145,88 +5136,80 @@ def update( self.widget.see(row_iid) def _handle_scroll(self, x0, x1): - if float(x0) == 0.0: - self._handle_start_scroll() + if float(x0) == 0.0 and self._start_index > 0: + with self._lock: + self._handle_start_scroll() return - if float(x1) == 1.0: - self._handle_end_scroll() + if float(x1) == 1.0 and self._end_index < len(self.values): + with self._lock: + self._handle_end_scroll() return # else, set the scroll self.vsb.set(x0, x1) def _handle_start_scroll(self): - with self._lock: - if self._start_index > 0: - # determine slice - num_rows = min(self._start_index, self.insert_qty) - new_start_index = max(0, self._start_index - num_rows) - new_rows = self.values[new_start_index : self._start_index] - - # insert - for row in reversed(new_rows): - iid = self.TKTreeview.insert( - "", "0", text=row, iid=row.pk, values=row, tag=row.pk - ) - self._start_alt_color = self._set_background( - iid, self._start_alt_color - ) - self.tree_ids.insert(0, iid) + # determine slice + num_rows = min(self._start_index, self.insert_qty) + new_start_index = max(0, self._start_index - num_rows) + new_rows = self.values[new_start_index : self._start_index] + + # insert + for row in reversed(new_rows): + iid = self.widget.insert( + "", "0", text=row, iid=row.pk, values=row, tag=row.pk + ) + self._start_alt_color = self._set_colors(iid, self._start_alt_color) + self.tree_ids.insert(0, iid) - # set new start - self._start_index = new_start_index + # set new start + self._start_index = new_start_index - # Insert new_rows to beginning - # don't use data.insert(0, new_rows), it breaks TableRow - self.data[:0] = new_rows + # Insert new_rows to beginning + # don't use data.insert(0, new_rows), it breaks TableRow + self.data[:0] = new_rows - # to avoid an infinite scroll, move scroll a little after 0.0 - with contextlib.suppress(IndexError): - row_iid = self.tree_ids[self.insert_qty + self.NumRows - 1] - self.widget.see(row_iid) + # to avoid an infinite scroll, move scroll a little after 0.0 + with contextlib.suppress(IndexError): + row_iid = self.tree_ids[self.insert_qty + self.NumRows - 1] + self.widget.see(row_iid) def _handle_end_scroll(self): - with self._lock: - num_rows = len(self.values) - if self._end_index < num_rows: - # determine slice - start_index = max(0, self._end_index) - end_index = min(self._end_index + self.insert_qty, num_rows) - new_rows = self.values[start_index:end_index] - - # insert - for row in new_rows: - iid = self.widget.insert( - "", "end", text=row, iid=row.pk, values=row, tag=row.pk - ) - self._end_alt_color = self._set_background(iid, self._end_alt_color) - self.tree_ids.append(iid) - - # set new end - self._end_index = end_index + num_rows = len(self.values) + # determine slice + start_index = max(0, self._end_index) + end_index = min(self._end_index + self.insert_qty, num_rows) + new_rows = self.values[start_index:end_index] + + # insert + for row in new_rows: + iid = self.widget.insert( + "", "end", text=row, iid=row.pk, values=row, tag=row.pk + ) + self._end_alt_color = self._set_colors(iid, self._end_alt_color) + self.tree_ids.append(iid) - # Extend self.data with new_rows - self.data.extend(new_rows) + # set new end + self._end_index = end_index - # to avoid an infinite scroll, move scroll a little before 1.0 - with contextlib.suppress(IndexError): - row_iid = self.tree_ids[len(self.data) - self.insert_qty] - self.widget.see(row_iid) + # Extend self.data with new_rows + self.data.extend(new_rows) - def _set_background(self, iid, toggle_color): - # first set background - if ( - self.BackgroundColor is not None - and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT - ): - self.TKTreeview.tag_configure(iid, background=self.BackgroundColor) - else: - self.TKTreeview.tag_configure(iid, background="#FFFFFF") + # to avoid an infinite scroll, move scroll a little before 1.0 + with contextlib.suppress(IndexError): + row_iid = self.tree_ids[len(self.data) - self.insert_qty] + self.widget.see(row_iid) - # then override with alt color if set + def _set_colors(self, iid, toggle_color): if self.AlternatingRowColor is not None: if not toggle_color: - self.TKTreeview.tag_configure(iid, background=self.AlternatingRowColor) + self.widget.tag_configure( + iid, background=self.AlternatingRowColor, foreground=self._fg + ) + else: + self.widget.tag_configure(iid, background=self._bg, foreground=self._fg) toggle_color = not toggle_color + else: + self.widget.tag_configure(iid, background=self._bg, foreground=self._fg) return toggle_color @property @@ -5237,7 +5220,7 @@ def SelectedRows(self): :returns: - If the LazyTable has data: - Retrieves the index of the selected row by matching the primary key - (pk) value with the first selected item in the TKTreeview. + (pk) value with the first selected item in the widget. - Returns the corresponding row from the data list based on the index. - If the LazyTable has no data: - Returns None. @@ -5249,7 +5232,7 @@ def SelectedRows(self): if self.data: index = [ [v.pk for v in self.data].index( - [int(x) for x in self.TKTreeview.selection()][0] + [int(x) for x in self.widget.selection()][0] ) ][0] return self.data[index] From 0b7b74d860010e50b675adde4d8d9445587bedd0 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 27 May 2023 16:14:02 -0400 Subject: [PATCH 59/66] Cleaning up sg.table to support pypi version and github --- pysimplesql/pysimplesql.py | 57 +++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 709ff812..e39250bb 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5027,7 +5027,7 @@ def __init__(self, *args, **kwargs): self.data = [] # lazy slice of rows self.Values = self.data - self.insert_qty = max(self.NumRows, 25) + self.insert_qty = max(self.NumRows, 100) """Number of rows to insert during an `update(values=)` and scroll events""" self._start_index = 0 @@ -5047,11 +5047,39 @@ def update( select_rows=None, alternating_row_color=None, ): + # check if we shouldn't be doing this update + # PySimpleGUI version support (PyPi version doesn't support quick_check) + if sg.__version__.split(".")[0] == "5": + quick_check = "quick_check=True" + elif sg.__version__.split(".")[0] == "4": + if sg.__version__.split(".")[1] == "61": + quick_check = "quick_check=True" + else: + quick_check = "" + + if not self._widget_was_created() or ( + self.ParentForm is not None and self.ParentForm.is_closed(quick_check) + ): + return + + # update total list self.values = values # Update current_index with the selected index self.current_index = select_rows[0] if select_rows else 0 - # determine bg + # needed, since PySimpleGUI doesn't create tk widgets during class init + if not self._finalized: + self.widget.configure(yscrollcommand=self._handle_scroll) + self._finalized = True + + # delete all current + children = self.widget.get_children() + for i in children: + self.widget.detach(i) + self.widget.delete(i) + self.tree_ids = [] + + # background color self._bg = ( self.BackgroundColor if self.BackgroundColor is not None @@ -5059,39 +5087,18 @@ def update( else "#FFFFFF" ) - # determine fg + # text color self._fg = ( self.TextColor if self.TextColor is not None and self.TextColor != sg.COLOR_SYSTEM_DEFAULT else "#000000" ) - if not self._widget_was_created(): - return - - # needed, since PySimpleGUI doesn't create tk widgets during class init - if not self._finalized: - self.widget.configure(yscrollcommand=self._handle_scroll) - self._finalized = True - - if self._this_elements_window_closed(): - return - + # alternating color if alternating_row_color is not None: self.AlternatingRowColor = alternating_row_color self._start_alt_color = True - # remove tags # TODO: this may not be necessary - for iid in self.tree_ids: - self.TKTreeview.item(iid, tags=()) - - # delete all current - children = self.widget.get_children() - for i in children: - self.widget.detach(i) - self.widget.delete(i) - self.tree_ids = [] - # get values to insert if select_rows is not None: # Slice the list to show visible rows before and after the current index From ea9ea8952bdf18fba848441bf7b93beda1b27af4 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 27 May 2023 16:19:02 -0400 Subject: [PATCH 60/66] Small comment --- pysimplesql/pysimplesql.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index e39250bb..012a766d 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5247,8 +5247,7 @@ def SelectedRows(self): def __setattr__(self, name, value): if name == "SelectedRows": - # Custom handling for 'SelectedRows' attribute assignment - # Example: Prevent assignment of non-list values + # Handle PySimpleGui attempts to set our SelectedRows property return super().__setattr__(name, value) From 7b93bd19d2d89dbce611b48d41a7a7bedeb5405f Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 28 May 2023 14:45:56 -0400 Subject: [PATCH 61/66] Start Work on Readme --- README2.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 README2.md diff --git a/README2.md b/README2.md new file mode 100644 index 00000000..a2651358 --- /dev/null +++ b/README2.md @@ -0,0 +1,30 @@ + + + +

+ + + + pysimplesql logo + +

+ +Write data-driven desktop apps fast! Lightweight Python library supports SQLite, MySQL/MariaDB, PostgreSQL, Flatfile CSV, SQL Server and MS Access. Uses PySimpleGUI layouts. + +--- + +![gif here](https://) + +**[Features](#features)** - **[Requirements](#requirements)** - **[Installation](#installation)** - **[Quick usage](#quick-usage)** + +## Features + +- [**Test**](https://) + +## Installation + +## Quick usage + + + +See the [Usage](https://pysimplesql.github.io/pysimplesql/) section of the docs for more examples! \ No newline at end of file From 217030339ad3bacae61e09d179902307bb66e8ea Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 28 May 2023 14:57:53 -0400 Subject: [PATCH 62/66] Update README2.md --- README2.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README2.md b/README2.md index a2651358..2b80cc8e 100644 --- a/README2.md +++ b/README2.md @@ -20,11 +20,21 @@ Write data-driven desktop apps fast! Lightweight Python library supports SQLite, ## Features - [**Test**](https://) +- Design user interfaces with automatic relationship handling + +## Requirements + +Dependencies here, along with link to sqldriver specific page ## Installation -## Quick usage +- **Unix/macOS**: `python3 -m pip install pysimplesql` +- **Windows**: `py -m pip install pysimplesql` +## Quick usage +``` +Ultra simple example here +``` See the [Usage](https://pysimplesql.github.io/pysimplesql/) section of the docs for more examples! \ No newline at end of file From cd86f626a56b425eedb2da65956543af3801e2aa Mon Sep 17 00:00:00 2001 From: PySimpleSQL Date: Sun, 28 May 2023 22:38:32 -0400 Subject: [PATCH 63/66] replaced with for linux compatibility --- pysimplesql/pysimplesql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 012a766d..6c98a2cb 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -5421,7 +5421,7 @@ def disable_placeholder_select(event): self.binds[""] = widget.bind("", on_key, "+") self.binds[""] = widget.bind("", on_focusin, "+") self.binds[""] = widget.bind("", on_focusout, "+") - for event in ["<>", "", ""]: + for event in ["<>", "", ""]: self.binds[event] = widget.bind(event, disable_placeholder_select, "+") if not widget.get(): From dc070e1b7b76ad3f7f3ce22b4f2dd101de7ac4c6 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Mon, 29 May 2023 08:07:49 -0400 Subject: [PATCH 64/66] Fix for add_placeholder missing --- pysimplesql/pysimplesql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 6c98a2cb..da7e1db3 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -6805,7 +6805,7 @@ def actions( ] else: layout += [ - sg.Input("", key=keygen.get(f"{key}search_input"), size=search_size), + Input("", key=keygen.get(f"{key}search_input"), size=search_size), sg.B( themepack.search, key=keygen.get(f"{key}search_button"), From 4541de7c1397f6d11b879882a0862fc8fbde9db5 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Mon, 29 May 2023 16:33:46 -0400 Subject: [PATCH 65/66] fix search to start at current position, +1, and wrap back around to beginning --- pysimplesql/pysimplesql.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index da7e1db3..90507745 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -1414,7 +1414,10 @@ def search( if self.row_count: logger.debug(f"DEBUG: {self.search_order} {self.rows.columns[0]}") - rows = self.rows.copy() + # reorder rows to be idx + 1, and wrap around back to the beginning + rows = self.rows.copy().reset_index() + idx = self.current_index + 1 % len(rows) + rows = pd.concat([rows.loc[idx:], rows.loc[:idx]]) # fill in descriptions for cols in search_order rels = Relationship.get_relationships(self.table) From b47b125afe7fcaea8084daad1e4343701e96d5de Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 30 May 2023 08:36:30 -0400 Subject: [PATCH 66/66] Renaming subclassed widgets --- pysimplesql/pysimplesql.py | 54 ++++++++++++++------------------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 90507745..46852ea0 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -3415,7 +3415,7 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: self.map_element( element, self[table], col, where_column, where_value ) - if isinstance(element, (Input, Multiline)) and ( + if isinstance(element, (_EnhancedInput, _EnhancedMultiline)) and ( col in self[table].column_info.names() and self[table].column_info[col].notnull ): @@ -4994,23 +4994,6 @@ def reset_from_form(self, frm: Form) -> None: "database": "pysimplesql_examples", } - -# ------------------------------------------------------------------------------------- -# WIDGETS -# ------------------------------------------------------------------------------------- -class Widgets: - - """ - pysimplesql extends several PySimpleGUI elements with further functionality. - See `Input`, `Multiline` and `Combo`. - - Note: This is a dummy class that exists purely to enhance documentation and has no - use to the end user. - """ - - pass - - class LazyTable(sg.Table): """ @@ -5019,9 +5002,12 @@ class LazyTable(sg.Table): DataSets that contain thousands of rows, there may be some noticeable lag. LazyTable overcomes this by only inserting a slice of rows during an `update()`. - To use it, provide values in the form of [TableRow(pk, values)], finalize the - sg.Window, and call update(). Please note that LazyTable does not support the - `sg.Table` `row_colors` argument. + To use, simply replace `sg.Table` with `ss.LazyTable` as the `element` argument in a + selector() function call in your layout. + + Expects values in the form of [TableRow(pk, values)], and only becomes active after + a update(values=, selected_rows=[int]) call. Please note that LazyTable does not + support the `sg.Table` `row_colors` argument. """ def __init__(self, *args, **kwargs): @@ -5356,7 +5342,7 @@ def get(self) -> str: return super().get() -class Input(_PlaceholderText, sg.Input): +class _EnhancedInput(_PlaceholderText, sg.Input): """ An Input that allows for the display of a placeholder text when empty. """ @@ -5431,7 +5417,7 @@ def disable_placeholder_select(event): enable_placeholder() -class Multiline(_PlaceholderText, sg.Multiline): +class _EnhancedMultiline(_PlaceholderText, sg.Multiline): """ A Multiline that allows for the display of a placeholder text when focus-out empty. """ @@ -5508,7 +5494,7 @@ def _autocomplete_combo(widget, completion_list, delta=0): return hits -class Combo(sg.Combo): +class _AutocompleteCombo(sg.Combo): """Customized Combo widget with autocompletion feature. Please note that due to how PySimpleSql initilizes widgets, you must call update() @@ -6233,7 +6219,7 @@ class Convenience: def field( field: str, - element: Type[sg.Element] = Input, + element: Type[sg.Element] = _EnhancedInput, size: Tuple[int, int] = None, label: str = "", no_label: bool = False, @@ -6274,9 +6260,9 @@ def field( Column, but can be treated as a single Element. """ # TODO: See what the metadata does after initial setup is complete - needed anymore? - element = Input if element == sg.Input else element - element = Multiline if element == sg.Multiline else element - element = Combo if element == sg.Combo else element + element = _EnhancedInput if element == sg.Input else element + element = _EnhancedMultiline if element == sg.Multiline else element + element = _AutocompleteCombo if element == sg.Combo else element if use_ttk_buttons is None: use_ttk_buttons = themepack.use_ttk_buttons @@ -6307,7 +6293,7 @@ def field( else: first_param = "" - if element == Multiline: + if element == _EnhancedMultiline: layout_element = element( first_param, key=key, @@ -6335,7 +6321,7 @@ def field( }, **kwargs, ) - layout_label = sg.T( + layout_label = sg.Text( label if label else label_text, size=themepack.default_label_size, key=f"{key}:label", @@ -6361,7 +6347,7 @@ def field( else: layout = [[layout_label, layout_marker, layout_element]] # Add the quick editor button where appropriate - if element == Combo and quick_editor: + if element == _AutocompleteCombo and quick_editor: meta = { "type": TYPE_EVENT, "event_type": EVENT_QUICK_EDIT, @@ -6793,7 +6779,7 @@ def actions( } if type(themepack.search) is bytes: layout += [ - Input("", key=keygen.get(f"{key}search_input"), size=search_size), + _EnhancedInput("", key=keygen.get(f"{key}search_input"), size=search_size), sg.B( "", key=keygen.get(f"{key}search_button"), @@ -6808,7 +6794,7 @@ def actions( ] else: layout += [ - Input("", key=keygen.get(f"{key}search_input"), size=search_size), + _EnhancedInput("", key=keygen.get(f"{key}search_input"), size=search_size), sg.B( themepack.search, key=keygen.get(f"{key}search_button"), @@ -6874,7 +6860,7 @@ def selector( key=key, metadata=meta, ) - elif element == Combo: + elif element == _AutocompleteCombo: w = themepack.default_element_size[0] layout = element( values=(),