From 60bc536800d26a531ba9a90382539bd1dcd27f98 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 11 Jul 2023 00:10:52 -0400 Subject: [PATCH 1/3] TableBuilder initial --- examples/orders_multiple_databases.py | 65 ++--- pysimplesql/pysimplesql.py | 361 ++++++++++++++++++-------- 2 files changed, 287 insertions(+), 139 deletions(-) diff --git a/examples/orders_multiple_databases.py b/examples/orders_multiple_databases.py index 850add4d..0c09a5e6 100644 --- a/examples/orders_multiple_databases.py +++ b/examples/orders_multiple_databases.py @@ -33,8 +33,8 @@ # ----------------------------- custom = { "ttk_theme": os_ttktheme, - "marker_sort_asc": " ⬇", - "marker_sort_desc": " ⬆", + "marker_sort_asc": " ⬇ ", + "marker_sort_desc": " ⬆ ", } custom = custom | os_tp ss.themepack(custom) @@ -216,7 +216,7 @@ def is_valid_email(email): """ # Generate random orders using pandas DataFrame -num_orders = 100 +num_orders = 2000 rng = np.random.default_rng() orders_df = pd.DataFrame( { @@ -389,23 +389,32 @@ def is_valid_email(email): # fmt: on layout = [[sg.Menu(menu_def, key="-MENUBAR-", font="_ 12")]] -# Define the columns for the table selector using the TableHeading class. -order_heading = ss.TableHeadings( - sort_enable=True, # Click a heading to sort +# Set our universal table options +table_style = ss.TableStyle(row_height=25, expand_x=True, expand_y=True) + +# Define the columns for the table selector using the Tabletable class. +order_table = ss.TableBuilder( + num_rows=5, + sort_enable=True, # Click a table to sort allow_cell_edits=True, # Double-click a cell to make edits. # Exempted: Primary Key columns, Generated columns, and columns set as readonly add_save_heading_button=True, # Click 💾 in sg.Table Heading to trigger DataSet.save_record() apply_search_filter=True, # Filter rows as you type in the search input + style_options=table_style, ) # Add columns -order_heading.add_column(column="order_id", heading_column="ID", width=5) -order_heading.add_column("customer_id", "Customer", 30) -order_heading.add_column("date", "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) +order_table.add_column(column="order_id", heading_column="ID", width=5) +order_table.add_column("customer_id", "Customer", 30) +order_table.add_column("date", "Date", 20) +order_table.add_column( + column = "total", + heading_column = "Total", + width=10, + readonly=True, # set to True to disable editing for individual columns! + justify='right', # default, "left". Available: "left", "right", "center" +) +order_table.add_column("completed", "✔", 8) # Layout layout.append( @@ -414,10 +423,7 @@ def is_valid_email(email): [ ss.selector( "orders", - sg.Table, - num_rows=5, - headings=order_heading, - row_height=25, + order_table, ) ], [ss.actions("orders")], @@ -425,14 +431,18 @@ def is_valid_email(email): ] ) -# order_details TableHeadings: -details_heading = ss.TableHeadings( - sort_enable=True, allow_cell_edits=True, add_save_heading_button=True +# order_details TableBuilder: +details_table = ss.TableBuilder( + num_rows=10, + sort_enable=True, + allow_cell_edits=True, + add_save_heading_button=True, + style_options=table_style, ) -details_heading.add_column("product_id", "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, readonly=True) +details_table.add_column("product_id", "Product", 30) +details_table.add_column("quantity", "Quantity", 10, justify="right") +details_table.add_column("price", "Price/Ea", 10, readonly=True, justify="right") +details_table.add_column("subtotal", "Subtotal", 10, readonly=True, justify="right") orderdetails_layout = [ [sg.Sizer(h_pixels=0, v_pixels=10)], @@ -451,10 +461,7 @@ def is_valid_email(email): [ ss.selector( "order_details", - sg.Table, - num_rows=10, - headings=details_heading, - row_height=25, + details_table, ) ], [ss.actions("order_details", default=False, save=True, insert=True, delete=True)], @@ -479,9 +486,7 @@ def is_valid_email(email): ) # 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["order_details:selector"].expand(True, True) win["order_details:selector"].table_frame.pack(expand=True, fill="both") # Init pysimplesql Driver and Form diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 1e13a728..3cedb43b 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -82,6 +82,7 @@ ClassVar, Dict, List, + Literal, Optional, Tuple, Type, @@ -236,6 +237,7 @@ EMPTY = ["", None] DECIMAL_PRECISION = 12 DECIMAL_SCALE = 2 +ColumnJustify = Literal["left", "right", "center"] # -------------------- # Date formats @@ -2647,13 +2649,13 @@ def column_likely_in_selector(self, column: str) -> bool: return False # If table headings are not used, assume the column is displayed, return True - if not any("TableHeading" in e["element"].metadata for e in self.selector): + if not any("TableBuilder" in e["element"].metadata for e in self.selector): return True # Otherwise, Return True/False if the column is in the list of table headings return any( - "TableHeading" in e["element"].metadata - and column in e["element"].metadata["TableHeading"].columns() + "TableBuilder" in e["element"].metadata + and column in e["element"].metadata["TableBuilder"].columns for e in self.selector ) @@ -2787,8 +2789,8 @@ def quick_editor( keygen.reset() data_key = self.key layout = [] - headings = TableHeadings( - sort_enable=True, allow_cell_edits=True, add_save_heading_button=True + table_builder = TableBuilder( + num_rows=10,sort_enable=True, allow_cell_edits=True, add_save_heading_button=True,style_options=TableStyle(row_height=25) ) for col in self.column_info.names(): @@ -2797,17 +2799,22 @@ 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) + 1) - headings.add_column(col, col.capitalize(), width=width) + justify = 'left' + elif ( + self.column_info[col] + and self.column_info[col].python_type in [int, float, Decimal] + ): + justify = 'right' + else: + justify = 'left' + table_builder.add_column(col, col.capitalize(), width=width, justify=justify) layout.append( [ selector( data_key, - sg.Table, + table_builder, key=f"{data_key}:quick_editor", - num_rows=10, - row_height=25, - headings=headings, ) ] ) @@ -3097,10 +3104,10 @@ def update_headings(self, column, sort_order): for e in self.selector: element = e["element"] if ( - "TableHeading" in element.metadata - and element.metadata["TableHeading"].sort_enable + "TableBuilder" in element.metadata + and element.metadata["TableBuilder"].sort_enable ): - element.metadata["TableHeading"].update_headings( + element.metadata["TableBuilder"].update_headings( element, column, sort_order ) @@ -3757,19 +3764,19 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: element, data_key, where_column, where_value ) - # Enable sorting if TableHeading is present + # Enable sorting if TableBuilder is present if ( isinstance(element, sg.Table) - and "TableHeading" in element.metadata + and "TableBuilder" in element.metadata ): - table_heading: TableHeadings = element.metadata["TableHeading"] + table_builder: TableBuilder = element.metadata["TableBuilder"] # We need a whole chain of things to happen # when a heading is clicked on: # 1 Run the ResultRow.sort_cycle() with the correct column name - # 2 Run TableHeading.update_headings() with the: + # 2 Run TableBuilder.update_headings() with the: # Table element, sort_column, sort_reverse # 3 Run update_elements() to see the changes - table_heading.enable_heading_function( + table_builder.enable_heading_function( element, _HeadingCallback(self, data_key), ) @@ -4523,9 +4530,9 @@ def update_selectors( # Populate entries apply_search_filter = False try: - columns = element.metadata["TableHeading"].columns() + columns = element.metadata["TableBuilder"].columns apply_search_filter = element.metadata[ - "TableHeading" + "TableBuilder" ].apply_search_filter except KeyError: columns = None # default to all columns @@ -5393,7 +5400,6 @@ def reset_from_form(self, frm: Form) -> None: See `KeyGen` for more info """ - class LazyTable(sg.Table): """ @@ -5410,14 +5416,14 @@ class LazyTable(sg.Table): support the `sg.Table` `row_colors` argument. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, lazy_loading=False, heading_justify_list = None, **kwargs): super().__init__(*args, **kwargs) - self.values = [] # noqa PD011 # full set of rows - self.data = [] # lazy slice of rows - self.Values = self.data - self.insert_qty = max(self.NumRows, 100) - """Number of rows to insert during an `update(values=)` and scroll events""" + self.finalized = False + self.data = [] # lazy slice of rows + self.lazy_loading: bool = True + self.lazy_insert_qty: int = 100 + self.heading_justify_list = heading_justify_list self._start_index = 0 self._end_index = 0 @@ -5427,6 +5433,13 @@ def __init__(self, *args, **kwargs): self._lock = threading.Lock() self._bg = None self._fg = None + + @property + def insert_qty(self): + """Number of rows to insert during an `update(values=)` and scroll events""" + if self.lazy_loading: + return max(self.NumRows, self.lazy_insert_qty) + return len(self.Values) def update( self, @@ -5436,6 +5449,11 @@ def update( select_rows=None, alternating_row_color=None, ): + if not self.finalized: + for i, column_id in enumerate(self.Widget["columns"]): + self.Widget.heading(column_id)["anchor"] = self.heading_justify_list[i] + self.finalized = True + # 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" or ( @@ -6239,7 +6257,7 @@ class Convenience: 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`. + convenience functions: `field()`, `selector()`, `actions()`, `TableBuilder`. Note: This is a dummy class that exists purely to enhance documentation and has no use to the end user. @@ -6248,7 +6266,12 @@ class Convenience: def field( field: str, - element: Type[sg.Element] = _EnhancedInput, + element: Union[ + Type[sg.Checkbox], + Type[sg.Combo], + Type[sg.Input], + Type[sg.Multiline], + ] = _EnhancedInput, size: Tuple[int, int] = None, label: str = "", no_label: bool = False, @@ -6848,7 +6871,14 @@ def actions( def selector( table: str, - element: Type[sg.Element] = sg.LBox, + element: Union[ + Type[sg.Combo], + Type[LazyTable], + Type[sg.Listbox], + Type[sg.Slider], + Type[sg.Table], + TableBuilder, + ] = sg.Listbox, size: Tuple[int, int] = None, filter: str = None, key: str = None, @@ -6861,18 +6891,18 @@ def selector( convenience function makes creating selectors very quick and as easy as using a normal PySimpleGUI element. - :param table: The table name in the database that this selector will act on + :param table: The table name 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. + will default to table:selector using the name 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. + PySimpleGUI element. Note: TableBuilder objects bring their own kwargs. """ element = _AutocompleteCombo if element == sg.Combo else element @@ -6909,22 +6939,18 @@ def selector( metadata=meta, ) 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 - 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." - ) + 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." + ) + # Create a narrow column for displaying a * character for virtual rows. + # This will be the 1st column + kwargs["headings"].insert(0, "") + kwargs["visible_column_map"].insert(0, 1) + if "col_widths" in kwargs: + kwargs["col_widths"].insert(0, themepack.unsaved_column_width) # Create other kwargs that are required kwargs["enable_events"] = True @@ -6933,105 +6959,137 @@ def selector( # Make an empty list of values vals = [[""] * len(kwargs["headings"])] - - # 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) - - # 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"].add_save_heading_button: - 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) + elif isinstance(element, TableBuilder): + table_builder = element + element = table_builder.element + lazy = table_builder.lazy_loading + kwargs = table_builder.get_table_kwargs() + if 'heading_justify_list' in kwargs: + heading_justify_list = kwargs.pop('heading_justify_list') + meta["TableBuilder"] = table_builder + # Make an empty list of values + vals = [[""] * len(kwargs["headings"])] + layout = element(vals, lazy_loading=lazy, heading_justify_list=heading_justify_list, key=key, metadata=meta, **kwargs) else: raise RuntimeError(f'Element type "{element}" not supported as a selector.') return layout -class TableHeadings(list): +@dc.dataclass +class TableBuilder(list): """ This is a convenience class used to build table headings for PySimpleGUI. - In addition, `TableHeading` objects can sort columns in ascending or descending + In addition, `TableBuilder` 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. + + :param element: Element to use with this TableBuilder. Default, `sg.Table`. Also + available, `ss.LazyTable`, for larger DataSets (see documentation). + :param sort_enable: True to enable sorting by heading column. + :param allow_cell_edits: Double-click to edit a cell value if True. Accepted + edits update both `sg.Table` and associated `field` element. Note: primary + key, generated, or `readonly` columns don't allow cell edits. + :param add_save_heading_button: Adds a save button to the left-most heading + column if True. + :param apply_search_filter: Filter rows to only those columns in + `DataSet.search_order` that contain `Dataself.search_string`. + :returns: None """ # store our instances - instances: ClassVar[List[TableHeadings]] = [] + instances: ClassVar[List[TableBuilder]] = [] - def __init__( - self, - sort_enable: bool = True, - allow_cell_edits: bool = False, - add_save_heading_button: bool = False, - apply_search_filter: bool = False, - ) -> None: - """ - Create a new TableHeadings object. + num_rows: int + sort_enable: bool = True + allow_cell_edits: bool = False + apply_search_filter: bool = False + lazy_loading: bool = False + style_options: TableStyle = None + add_save_heading_button: bool = False - :param sort_enable: True to enable sorting by heading column. - :param allow_cell_edits: Double-click to edit a cell value if True. Accepted - edits update both `sg.Table` and associated `field` element. Note: primary - key, generated, or `readonly` columns don't allow cell edits. - :param add_save_heading_button: Adds a save button to the left-most heading - column if True. - :param apply_search_filter: Filter rows to only those columns in - `DataSet.search_order` that contain `Dataself.search_string`. - :returns: None - """ - self.sort_enable = sort_enable - self.allow_cell_edits = allow_cell_edits - self.add_save_heading_button = add_save_heading_button - self.apply_search_filter = apply_search_filter - self._width_map = [] - self._visible_map = [] - self.readonly_columns = [] + _width_map: List[int] = dc.field(default_factory=lambda: [], init=False) + _justify_map: List[int] = dc.field(default_factory=lambda: [], init=False) + _visible_map: List[bool] = dc.field(default_factory=lambda: [], init=False) + readonly_columns: List[str] = dc.field(default_factory=lambda: [], init=False) + def __post_init__(self): # Store this instance in the master list of instances - TableHeadings.instances.append(self) + TableBuilder.instances.append(self) + if self.add_save_heading_button: + self.insert(0, themepack.unsaved_column_header) + else: + self.insert(0, "") def add_column( self, column: str, heading_column: str, width: int, + justify: ColumnJustify = "left", visible: bool = True, readonly: bool = False, ) -> None: """ - Add a new heading column to this TableHeading object. Columns are added in the + Add a new heading column to this TableBuilder 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 justify: Default 'left'. Available options: 'left', 'right', 'center'. + Only available on supported PySimpleGUI versions (>=4.61). :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.allow_cell_edits` is True. + `TableBuilder.allow_cell_edits` is True. :returns: None """ self.append({"heading": heading_column, "column": column}) self._width_map.append(width) + self._justify_map.append(justify) self._visible_map.append(visible) if readonly: self.readonly_columns.append(column) + def get_table_kwargs(self) -> Dict[str]: + kwargs = {} + + kwargs["num_rows"] = self.num_rows + + table_sig = inspect.signature(sg.Table) + if "cols_justification" in table_sig.parameters: + kwargs["heading_justify_list"] = self.heading_justify_map + kwargs["cols_justification"] = self.col_justify_map + else: + kwargs["justification"] = "left" + + kwargs["headings"] = self.heading_names + kwargs["visible_column_map"] = self.visible_map + kwargs["col_widths"] = self.width_map + + # Create a narrow column for displaying a * character for virtual rows. + # This will be the 1st column + kwargs["visible_column_map"].insert(0, 1) + kwargs["col_widths"].insert(0, themepack.unsaved_column_width) + + kwargs["auto_size_columns"] = False + kwargs["enable_events"] = True + kwargs["select_mode"] = sg.TABLE_SELECT_MODE_BROWSE + + kwargs = kwargs | self.style_options.get_non_default_attributes() + return kwargs + + @property + def element(self) -> Type[LazyTable]: + return LazyTable + + @property def heading_names(self) -> List[str]: """ Return a list of heading_names for use with the headings parameter of @@ -7039,8 +7097,14 @@ def heading_names(self) -> List[str]: :returns: a list of heading names """ + headings = [c["heading"] for c in self] + if self.add_save_heading_button: + headings.insert(0, themepack.unsaved_column_header) + else: + headings.insert(0, "") return [c["heading"] for c in self] + @property def columns(self): """ Return a list of column names. @@ -7049,6 +7113,36 @@ def columns(self): """ return [c["column"] for c in self if c["column"] is not None] + @property + def col_justify_map(self) -> List[str]: + """ + Convenience method for creating PySimpleGUI tables. + + :returns: a list column justifications for use with PySimpleGUI Table + cols_justification parameter + """ + justify = list(justify[0].lower() for justify in self._justify_map) + justify.insert(0, 'l') + return justify + + @property + def heading_justify_map(self) -> List[str]: + """ + Convenience method for creating PySimpleGUI tables. + + :returns: a list column justifications for use with PySimpleGUI Table + cols_justification parameter + """ + tk_map = { + "l" : "w", + "r" : "e", + "c" : "center", + } + justify = list(tk_map[justify[0].lower()] for justify in self._justify_map) + justify.insert(0, 'w') + return justify + + @property def visible_map(self) -> List[Union[bool, int]]: """ Convenience method for creating PySimpleGUI tables. @@ -7058,6 +7152,7 @@ def visible_map(self) -> List[Union[bool, int]]: """ return list(self._visible_map) + @property def width_map(self) -> List[int]: """ Convenience method for creating PySimpleGUI tables. @@ -7092,6 +7187,7 @@ def update_headings( desc = "\u25B2" for i, x in zip(range(len(self)), self): + anchor = self.heading_justify_map[i] # Clear the direction markers x["heading"] = x["heading"].replace(asc, "").replace(desc, "") if ( @@ -7099,8 +7195,12 @@ def update_headings( 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") + marker = asc if sort_order == SORT_ASC else desc + if anchor == 'e': + x["heading"] = marker + x["heading"] + else: + x["heading"] += marker + element.Widget.heading(i, text=x["heading"], anchor=self.heading_justify_map[i]) def enable_heading_function(self, element: sg.Table, fn: callable) -> None: """ @@ -7108,7 +7208,7 @@ def enable_heading_function(self, element: sg.Table, fn: callable) -> None: 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 + :param element: The PySimpleGUI Table element associated with this TableBuilder :param fn: A callback functions to run when a heading is clicked. The callback should take one column parameter. :returns: None @@ -7192,11 +7292,11 @@ def edit(self, event): if not element: return - # get table_headings - table_heading = element.metadata["TableHeading"] + # get table_builders + table_builder = element.metadata["TableBuilder"] # get column name - columns = table_heading.columns() + columns = table_builder.columns column = columns[col_idx - 1] # use table_element to distinguish @@ -7211,7 +7311,7 @@ def edit(self, event): if col_idx == 0: return - if column in table_heading.readonly_columns: + if column in table_builder.readonly_columns: logger.debug(f"{column} is readonly") return @@ -7223,7 +7323,7 @@ def edit(self, event): logger.debug(f"{column} is a generated column") return - if not table_heading.allow_cell_edits: + if not table_builder.allow_cell_edits: logger.debug("This Table element does not allow editing") return @@ -7500,7 +7600,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: + if element.widget == treeview and "TableBuilder" in element.metadata: return data_key, element return None @@ -11112,6 +11212,49 @@ class Driver: msaccess: callable = MSAccess +@dc.dataclass +class TableStyle: + row_height: int = None + font: str or Tuple[str, int] or None = None + text_color: str = None + background_color: str = None + alternating_row_color: str = None + selected_row_colors: Tuple[str, str] = (None, None) + header_text_color: str = None + header_background_color: str = None + header_font: str or Tuple[str, int] or None = None + header_border_width: int = None + header_relief: str = None + vertical_scroll_only: bool = True + hide_vertical_scroll: bool = False + border_width: int = None + sbar_trough_color: str = None + sbar_background_color: str = None + sbar_arrow_color: str = None + sbar_width: int = None + sbar_arrow_width: int = None + sbar_frame_color: str = None + sbar_relief: str = None + enable_click_events: bool = False + pad: Union[int, Tuple[int, int], Tuple[Tuple[int, int], Tuple[int, int]]] = None + tooltip: str = None + right_click_menu: List[Union[List[str], str]] = None + expand_x: bool = False + expand_y: bool = False + visible: bool = True + + def __repr__(self): + attrs = self.get_non_default_attributes() + return f"TableStyle({attrs})" + + def get_non_default_attributes(self): + non_default_attributes = {} + for field in dc.fields(self): + if getattr(self, field.name) != field.default: + non_default_attributes[field.name] = getattr(self, field.name) + return non_default_attributes + + SaveResultsDict = Dict[str, int] CallbacksDict = Dict[str, Callable[[Form, sg.Window], Union[None, bool]]] PromptSaveValue = ( From 0171079ca35d5f75b4bea41770627206417934db Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 11 Jul 2023 12:28:30 -0400 Subject: [PATCH 2/3] Refactoring --- examples/orders_multiple_databases.py | 38 ++-- pysimplesql/pysimplesql.py | 268 +++++++++++++++----------- 2 files changed, 177 insertions(+), 129 deletions(-) diff --git a/examples/orders_multiple_databases.py b/examples/orders_multiple_databases.py index 0c09a5e6..ea056f26 100644 --- a/examples/orders_multiple_databases.py +++ b/examples/orders_multiple_databases.py @@ -216,7 +216,7 @@ def is_valid_email(email): """ # Generate random orders using pandas DataFrame -num_orders = 2000 +num_orders = 1000 rng = np.random.default_rng() orders_df = pd.DataFrame( { @@ -390,7 +390,12 @@ def is_valid_email(email): layout = [[sg.Menu(menu_def, key="-MENUBAR-", font="_ 12")]] # Set our universal table options -table_style = ss.TableStyle(row_height=25, expand_x=True, expand_y=True) +table_style = ss.TableStyler( + row_height=25, + expand_x=True, + expand_y=True, + frame_pack_kwargs={"expand": True, "fill": "both"}, +) # Define the columns for the table selector using the Tabletable class. order_table = ss.TableBuilder( @@ -398,22 +403,23 @@ def is_valid_email(email): sort_enable=True, # Click a table to sort allow_cell_edits=True, # Double-click a cell to make edits. # Exempted: Primary Key columns, Generated columns, and columns set as readonly - add_save_heading_button=True, # Click 💾 in sg.Table Heading to trigger DataSet.save_record() apply_search_filter=True, # Filter rows as you type in the search input - style_options=table_style, + lazy_loading=True, # For larger DataSets, inserts slice of rows. See `LazyTable` + add_save_heading_button=True, # Click 💾 in sg.Table Heading to trigger DataSet.save_record() + style=table_style, ) # Add columns -order_table.add_column(column="order_id", heading_column="ID", width=5) +order_table.add_column(column="order_id", heading="ID", width=5) order_table.add_column("customer_id", "Customer", 30) order_table.add_column("date", "Date", 20) order_table.add_column( - column = "total", - heading_column = "Total", + column="total", + heading="Total", width=10, - readonly=True, # set to True to disable editing for individual columns! - justify='right', # default, "left". Available: "left", "right", "center" -) + readonly=True, # set to True to disable editing for individual columns! + col_justify="right", # default, "left". Available: "left", "right", "center" +) order_table.add_column("completed", "✔", 8) # Layout @@ -437,12 +443,12 @@ def is_valid_email(email): sort_enable=True, allow_cell_edits=True, add_save_heading_button=True, - style_options=table_style, + style=table_style, ) details_table.add_column("product_id", "Product", 30) -details_table.add_column("quantity", "Quantity", 10, justify="right") -details_table.add_column("price", "Price/Ea", 10, readonly=True, justify="right") -details_table.add_column("subtotal", "Subtotal", 10, readonly=True, justify="right") +details_table.add_column("quantity", "Quantity", 10, col_justify="right") +details_table.add_column("price", "Price/Ea", 10, readonly=True, col_justify="right") +details_table.add_column("subtotal", "Subtotal", 10, readonly=True, col_justify="right") orderdetails_layout = [ [sg.Sizer(h_pixels=0, v_pixels=10)], @@ -485,10 +491,6 @@ def is_valid_email(email): icon=ss.themepack.icon, ) -# Expand our sg.Tables so they fill the screen -win["orders:selector"].table_frame.pack(expand=True, fill="both") -win["order_details:selector"].table_frame.pack(expand=True, fill="both") - # Init pysimplesql Driver and Form # -------------------------------- if database == "sqlite": diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index 3cedb43b..ed7c5d2d 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -229,6 +229,11 @@ TK_CHECKBUTTON = "Checkbutton" TK_DATEPICKER = "Datepicker" TK_COMBOBOX_SELECTED = "35" +TK_ANCHOR_MAP = { + "l": "w", + "r": "e", + "c": "center", +} # -------------- # Misc Constants @@ -237,7 +242,9 @@ EMPTY = ["", None] DECIMAL_PRECISION = 12 DECIMAL_SCALE = 2 -ColumnJustify = Literal["left", "right", "center"] +TableJustify = Literal["left", "right", "center"] +ColumnJustify = Literal["left", "right", "center", "default"] +HeadingJustify = Literal["left", "right", "center", "column", "default"] # -------------------- # Date formats @@ -2790,7 +2797,11 @@ def quick_editor( data_key = self.key layout = [] table_builder = TableBuilder( - num_rows=10,sort_enable=True, allow_cell_edits=True, add_save_heading_button=True,style_options=TableStyle(row_height=25) + num_rows=10, + sort_enable=True, + allow_cell_edits=True, + add_save_heading_button=True, + style=TableStyler(row_height=25), ) for col in self.column_info.names(): @@ -2799,15 +2810,18 @@ 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) + 1) - justify = 'left' - elif ( - self.column_info[col] - and self.column_info[col].python_type in [int, float, Decimal] - ): - justify = 'right' + justify = "left" + elif self.column_info[col] and self.column_info[col].python_type in [ + int, + float, + Decimal, + ]: + justify = "right" else: - justify = 'left' - table_builder.add_column(col, col.capitalize(), width=width, justify=justify) + justify = "left" + table_builder.add_column( + col, col.capitalize(), width=width, justify=justify + ) layout.append( [ @@ -5400,6 +5414,7 @@ def reset_from_form(self, frm: Form) -> None: See `KeyGen` for more info """ + class LazyTable(sg.Table): """ @@ -5416,14 +5431,20 @@ class LazyTable(sg.Table): support the `sg.Table` `row_colors` argument. """ - def __init__(self, *args, lazy_loading=False, heading_justify_list = None, **kwargs): + def __init__(self, *args, lazy_loading=False, **kwargs): + # remove LazyTable only + self.headings_justification = kwargs.pop("headings_justification", None) + cols_justification = kwargs.pop("cols_justification", None) + self.frame_pack_kwargs = kwargs.pop("frame_pack_kwargs", None) + super().__init__(*args, **kwargs) - self.finalized = False + # set cols_justification after, since PySimpleGUI sets it in its init + self.cols_justification = cols_justification + self.data = [] # lazy slice of rows self.lazy_loading: bool = True self.lazy_insert_qty: int = 100 - self.heading_justify_list = heading_justify_list self._start_index = 0 self._end_index = 0 @@ -5433,7 +5454,13 @@ def __init__(self, *args, lazy_loading=False, heading_justify_list = None, **kwa self._lock = threading.Lock() self._bg = None self._fg = None - + + def __setattr__(self, name, value): + if name == "SelectedRows": + # Handle PySimpleGui attempts to set our SelectedRows property + return + super().__setattr__(name, value) + @property def insert_qty(self): """Number of rows to insert during an `update(values=)` and scroll events""" @@ -5441,6 +5468,32 @@ def insert_qty(self): return max(self.NumRows, self.lazy_insert_qty) return len(self.Values) + @property + def SelectedRows(self): # noqa N802 + """ + 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 widget. + - 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 and self.widget.selection(): + index = [ + [v.pk for v in self.data].index( + [int(x) for x in self.widget.selection()][0] + ) + ][0] + return self.data[index] + return None + def update( self, values=None, @@ -5449,19 +5502,12 @@ def update( select_rows=None, alternating_row_color=None, ): - if not self.finalized: - for i, column_id in enumerate(self.Widget["columns"]): - self.Widget.heading(column_id)["anchor"] = self.heading_justify_list[i] - self.finalized = True - # 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" or ( - sg.__version__.split(".")[0] == "4" and sg.__version__.split(".")[1] == "61" - ): + kwargs = {} + is_closed_sig = inspect.signature(self.ParentForm.is_closed) + if "quick_check" in is_closed_sig.parameters: kwargs = {"quick_check": True} - else: - kwargs = {} if not self._widget_was_created() or ( self.ParentForm is not None and self.ParentForm.is_closed(**kwargs) @@ -5476,6 +5522,7 @@ def update( # needed, since PySimpleGUI doesn't create tk widgets during class init if not self._finalized: self.widget.configure(yscrollcommand=self._handle_scroll) + self._handle_extra_kwargs() self._finalized = True # delete all current @@ -5625,37 +5672,15 @@ def _set_colors(self, iid, toggle_color): self.widget.tag_configure(iid, background=self._bg, foreground=self._fg) return toggle_color - @property - def SelectedRows(self): # noqa N802 - """ - 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 widget. - - 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 and self.widget.selection(): - index = [ - [v.pk for v in self.data].index( - [int(x) for x in self.widget.selection()][0] - ) - ][0] - return self.data[index] - return None - - def __setattr__(self, name, value): - if name == "SelectedRows": - # Handle PySimpleGui attempts to set our SelectedRows property - return - super().__setattr__(name, value) + def _handle_extra_kwargs(self): + if self.headings_justification: + for i, heading_id in enumerate(self.Widget["columns"]): + self.Widget.heading(heading_id, anchor=self.headings_justification[i]) + if self.cols_justification: + for i, column_id in enumerate(self.Widget["columns"]): + self.Widget.column(column_id, anchor=self.cols_justification[i]) + if self.frame_pack_kwargs: + self.table_frame.pack(**self.frame_pack_kwargs) class _StrictInput: @@ -5957,10 +5982,8 @@ def _on_search_string_change(self, *args): class _AutoCompleteLogic: - _completion_list: List[Union[str, ElementRow]] = dc.field( - default_factory=lambda: list - ) - _hits: List[int] = dc.field(default_factory=lambda: list) + _completion_list: List[Union[str, ElementRow]] = dc.field(default_factory=list) + _hits: List[int] = dc.field(default_factory=list) _hit_index: int = 0 position: int = 0 finalized: bool = False @@ -6965,12 +6988,17 @@ def selector( element = table_builder.element lazy = table_builder.lazy_loading kwargs = table_builder.get_table_kwargs() - if 'heading_justify_list' in kwargs: - heading_justify_list = kwargs.pop('heading_justify_list') + meta["TableBuilder"] = table_builder # Make an empty list of values vals = [[""] * len(kwargs["headings"])] - layout = element(vals, lazy_loading=lazy, heading_justify_list=heading_justify_list, key=key, metadata=meta, **kwargs) + layout = element( + vals, + lazy_loading=lazy, + key=key, + metadata=meta, + **kwargs, + ) else: raise RuntimeError(f'Element type "{element}" not supported as a selector.') @@ -6987,12 +7015,11 @@ class TableBuilder(list): order by clicking on the column in the heading in the PySimpleGUI Table element if the sort_enable parameter is set to True. - :param element: Element to use with this TableBuilder. Default, `sg.Table`. Also - available, `ss.LazyTable`, for larger DataSets (see documentation). :param sort_enable: True to enable sorting by heading column. :param allow_cell_edits: Double-click to edit a cell value if True. Accepted edits update both `sg.Table` and associated `field` element. Note: primary key, generated, or `readonly` columns don't allow cell edits. + :param lazy_loading: For larger DataSets (see `LazyTable`). :param add_save_heading_button: Adds a save button to the left-most heading column if True. :param apply_search_filter: Filter rows to only those columns in @@ -7008,17 +7035,19 @@ class TableBuilder(list): allow_cell_edits: bool = False apply_search_filter: bool = False lazy_loading: bool = False - style_options: TableStyle = None add_save_heading_button: bool = False + style: TableStyler = None - _width_map: List[int] = dc.field(default_factory=lambda: [], init=False) - _justify_map: List[int] = dc.field(default_factory=lambda: [], init=False) - _visible_map: List[bool] = dc.field(default_factory=lambda: [], init=False) - readonly_columns: List[str] = dc.field(default_factory=lambda: [], init=False) + _width_map: List[int] = dc.field(default_factory=list, init=False) + _col_justify_map: List[int] = dc.field(default_factory=list, init=False) + _heading_justify_map: List[int] = dc.field(default_factory=list, init=False) + _visible_map: List[bool] = dc.field(default_factory=list, init=False) + readonly_columns: List[str] = dc.field(default_factory=list, init=False) def __post_init__(self): # Store this instance in the master list of instances TableBuilder.instances.append(self) + if self.add_save_heading_button: self.insert(0, themepack.unsaved_column_header) else: @@ -7027,9 +7056,10 @@ def __post_init__(self): def add_column( self, column: str, - heading_column: str, + heading: str, width: int, - justify: ColumnJustify = "left", + col_justify: ColumnJustify = "default", + heading_justify: HeadingJustify = "column", visible: bool = True, readonly: bool = False, ) -> None: @@ -7038,11 +7068,13 @@ def add_column( 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 column: The name of the column in the database + :param heading: The name of this columns heading (title) :param width: The width for this column to display within the Table element - :param justify: Default 'left'. Available options: 'left', 'right', 'center'. - Only available on supported PySimpleGUI versions (>=4.61). + :param col_justify: Default 'left'. Available options: 'left', 'right', + 'center', 'default'. + :param heading_justify: Defaults to 'column' to match col_justify. Available + options: 'left', 'right', 'center', 'column', 'default'. :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. @@ -7050,41 +7082,45 @@ def add_column( `TableBuilder.allow_cell_edits` is True. :returns: None """ - self.append({"heading": heading_column, "column": column}) + self.append({"heading": heading, "column": column}) self._width_map.append(width) - self._justify_map.append(justify) + + # column justify + if col_justify == "default": + col_justify = self.style.justification + self._col_justify_map.append(col_justify) + + # heading justify + if heading_justify == "column": + heading_justify = col_justify + if heading_justify == "default": + heading_justify = self.style.justification + self._heading_justify_map.append(heading_justify) + self._visible_map.append(visible) if readonly: self.readonly_columns.append(column) def get_table_kwargs(self) -> Dict[str]: kwargs = {} - - kwargs["num_rows"] = self.num_rows - - table_sig = inspect.signature(sg.Table) - if "cols_justification" in table_sig.parameters: - kwargs["heading_justify_list"] = self.heading_justify_map - kwargs["cols_justification"] = self.col_justify_map - else: - kwargs["justification"] = "left" + kwargs["num_rows"] = self.num_rows + kwargs["headings_justification"] = self.heading_justify_map + kwargs["cols_justification"] = self.col_justify_map kwargs["headings"] = self.heading_names kwargs["visible_column_map"] = self.visible_map kwargs["col_widths"] = self.width_map + kwargs["auto_size_columns"] = False + kwargs["enable_events"] = True + kwargs["select_mode"] = sg.TABLE_SELECT_MODE_BROWSE # Create a narrow column for displaying a * character for virtual rows. # This will be the 1st column kwargs["visible_column_map"].insert(0, 1) kwargs["col_widths"].insert(0, themepack.unsaved_column_width) - kwargs["auto_size_columns"] = False - kwargs["enable_events"] = True - kwargs["select_mode"] = sg.TABLE_SELECT_MODE_BROWSE + return kwargs | self.style.get_table_kwargs() - kwargs = kwargs | self.style_options.get_non_default_attributes() - return kwargs - @property def element(self) -> Type[LazyTable]: return LazyTable @@ -7121,10 +7157,12 @@ def col_justify_map(self) -> List[str]: :returns: a list column justifications for use with PySimpleGUI Table cols_justification parameter """ - justify = list(justify[0].lower() for justify in self._justify_map) - justify.insert(0, 'l') + justify = [ + TK_ANCHOR_MAP[justify[0].lower()] for justify in self._col_justify_map + ] + justify.insert(0, "w") return justify - + @property def heading_justify_map(self) -> List[str]: """ @@ -7133,13 +7171,10 @@ def heading_justify_map(self) -> List[str]: :returns: a list column justifications for use with PySimpleGUI Table cols_justification parameter """ - tk_map = { - "l" : "w", - "r" : "e", - "c" : "center", - } - justify = list(tk_map[justify[0].lower()] for justify in self._justify_map) - justify.insert(0, 'w') + justify = [ + TK_ANCHOR_MAP[justify[0].lower()] for justify in self._heading_justify_map + ] + justify.insert(0, "w") return justify @property @@ -7196,11 +7231,13 @@ def update_headings( and sort_order != SORT_NONE ): marker = asc if sort_order == SORT_ASC else desc - if anchor == 'e': + if anchor == "e": x["heading"] = marker + x["heading"] else: x["heading"] += marker - element.Widget.heading(i, text=x["heading"], anchor=self.heading_justify_map[i]) + element.Widget.heading( + i, text=x["heading"], anchor=self.heading_justify_map[i] + ) def enable_heading_function(self, element: sg.Table, fn: callable) -> None: """ @@ -7223,8 +7260,8 @@ def enable_heading_function(self, element: sg.Table, fn: callable) -> None: if self.add_save_heading_button: 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}) + def insert(self, idx, heading: str, column: str = None, *args, **kwargs): + super().insert(idx, {"heading": heading, "column": column}) class _HeadingCallback: @@ -11213,7 +11250,12 @@ class Driver: @dc.dataclass -class TableStyle: +class TableStyler: + # pysimplesql specific + frame_pack_kwargs: Dict[str] = dc.field(default_factory=dict) + + # PySimpleGUI Table kwargs that are compatible with pysimplesql + justification: TableJustify = "left" row_height: int = None font: str or Tuple[str, int] or None = None text_color: str = None @@ -11244,13 +11286,17 @@ class TableStyle: visible: bool = True def __repr__(self): - attrs = self.get_non_default_attributes() - return f"TableStyle({attrs})" + attrs = self.get_table_kwargs() + return f"TableStyler({attrs})" - def get_non_default_attributes(self): + def get_table_kwargs(self): non_default_attributes = {} for field in dc.fields(self): - if getattr(self, field.name) != field.default: + if ( + getattr(self, field.name) != field.default + and getattr(self, field.name) + and field.name not in [] + ): non_default_attributes[field.name] = getattr(self, field.name) return non_default_attributes From 3249990426a5a1ef4ae68c1f44f19ff1d1e1d6ea Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 11 Jul 2023 14:49:53 -0400 Subject: [PATCH 3/3] update examples to work with new TableBuilder --- examples/Flatfile_examples/csv_test.py | 12 +- .../MSAccess_examples/journal_msaccess.py | 10 +- .../MySQL_examples/journal_mysql_docker.py | 10 +- .../journal_postgres_docker.py | 10 +- .../journal_sqlserver_docker.py | 10 +- examples/SQLite_examples/address_book.py | 12 +- examples/SQLite_examples/checkbox_behavior.py | 8 +- examples/SQLite_examples/journal_external.py | 10 +- examples/SQLite_examples/journal_internal.py | 14 ++- .../journal_with_data_manipulation.py | 10 +- examples/SQLite_examples/many_to_many.py | 8 +- examples/SQLite_examples/orders.py | 75 ++++++------ examples/SQLite_examples/selectors_demo.py | 10 +- examples/journal_multiple_databases.py | 12 +- examples/orders_multiple_databases.py | 2 +- pysimplesql/pysimplesql.py | 107 +++++++++--------- 16 files changed, 164 insertions(+), 156 deletions(-) diff --git a/examples/Flatfile_examples/csv_test.py b/examples/Flatfile_examples/csv_test.py index deb90c5c..84044903 100644 --- a/examples/Flatfile_examples/csv_test.py +++ b/examples/Flatfile_examples/csv_test.py @@ -13,14 +13,14 @@ # Create a simple layout for working with our flatfile data. # Note that you can set a specific table name to use, but here I am just using the defaul 'Flatfile' # Lets also use some sortable headers so that we can rearrange the flatfile data when saving -headings=ss.TableHeadings() -headings.add_column('name', 'Name', width=12) -headings.add_column('address', 'Address', width=25) -headings.add_column('phone', 'Phone #', width=10) -headings.add_column('email', 'EMail', width=25) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column('name', 'Name', width=12) +table_builder.add_column('address', 'Address', width=25) +table_builder.add_column('phone', 'Phone #', width=10) +table_builder.add_column('email', 'EMail', width=25) layout = [ - [ss.selector('Flatfile', sg.Table, num_rows=10, headings=headings)], + [ss.selector('Flatfile', table_builder)], [ss.field('Flatfile.name')], [ss.field('Flatfile.address')], [ss.field('Flatfile.phone')], diff --git a/examples/MSAccess_examples/journal_msaccess.py b/examples/MSAccess_examples/journal_msaccess.py index a1bb5093..6c57d9b4 100644 --- a/examples/MSAccess_examples/journal_msaccess.py +++ b/examples/MSAccess_examples/journal_msaccess.py @@ -16,13 +16,13 @@ # ------------------------- # Define the columns for the table selector using the TableHeading convenience class. # This will also allow sorting! -headings = ss.TableHeadings() -headings.add_column("title", "Title", width=40) -headings.add_column("entry_date", "Date", width=10) -headings.add_column("mood_id", "Mood", width=20) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column("title", "Title", width=40) +table_builder.add_column("entry_date", "Date", width=10) +table_builder.add_column("mood_id", "Mood", width=20) layout = [ - [ss.selector("Journal", sg.Table, num_rows=10, headings=headings)], + [ss.selector("Journal", table_builder)], [ss.actions("Journal")], [ ss.field("Journal.entry_date"), diff --git a/examples/MySQL_examples/journal_mysql_docker.py b/examples/MySQL_examples/journal_mysql_docker.py index 198875bb..b2ba1fbb 100644 --- a/examples/MySQL_examples/journal_mysql_docker.py +++ b/examples/MySQL_examples/journal_mysql_docker.py @@ -25,13 +25,13 @@ # ------------------------- # Define the columns for the table selector using the TableHeading convenience class. # This will also allow sorting! -headings = ss.TableHeadings() -headings.add_column("title", "Title", width=40) -headings.add_column("entry_date", "Date", width=10) -headings.add_column("mood_id", "Mood", width=20) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column("title", "Title", width=40) +table_builder.add_column("entry_date", "Date", width=10) +table_builder.add_column("mood_id", "Mood", width=20) layout = [ - [ss.selector("Journal", sg.Table, num_rows=10, headings=headings)], + [ss.selector("Journal", table_builder)], [ss.actions("Journal")], [ ss.field("Journal.entry_date"), diff --git a/examples/PostgreSQL_examples/journal_postgres_docker.py b/examples/PostgreSQL_examples/journal_postgres_docker.py index ee5847b6..7fdb0e5b 100644 --- a/examples/PostgreSQL_examples/journal_postgres_docker.py +++ b/examples/PostgreSQL_examples/journal_postgres_docker.py @@ -25,13 +25,13 @@ # ------------------------- # Define the columns for the table selector using the TableHeading convenience class. # This will also allow sorting! -headings = ss.TableHeadings() -headings.add_column("title", "Title", width=40) -headings.add_column("entry_date", "Date", width=10) -headings.add_column("mood_id", "Mood", width=20) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column("title", "Title", width=40) +table_builder.add_column("entry_date", "Date", width=10) +table_builder.add_column("mood_id", "Mood", width=20) layout = [ - [ss.selector("Journal", sg.Table, num_rows=10, headings=headings)], + [ss.selector("Journal", table_builder)], [ss.actions("Journal")], [ ss.field("Journal.entry_date"), diff --git a/examples/SQLServer_examples/journal_sqlserver_docker.py b/examples/SQLServer_examples/journal_sqlserver_docker.py index 028a4551..67ab7dc0 100644 --- a/examples/SQLServer_examples/journal_sqlserver_docker.py +++ b/examples/SQLServer_examples/journal_sqlserver_docker.py @@ -44,13 +44,13 @@ # ------------------------- # Define the columns for the table selector using the TableHeading convenience class. # This will also allow sorting! -headings = ss.TableHeadings(sort_enable=True) -headings.add_column("title", "Title", width=40) -headings.add_column("entry_date", "Date", width=10) -headings.add_column("mood_id", "Mood", width=20) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column("title", "Title", width=40) +table_builder.add_column("entry_date", "Date", width=10) +table_builder.add_column("mood_id", "Mood", width=20) layout = [ - [ss.selector("Journal", sg.Table, num_rows=10, headings=headings)], + [ss.selector("Journal", table_builder)], [ss.actions("Journal")], [ ss.field("Journal.entry_date"), diff --git a/examples/SQLite_examples/address_book.py b/examples/SQLite_examples/address_book.py index 864b88ed..09d91c81 100644 --- a/examples/SQLite_examples/address_book.py +++ b/examples/SQLite_examples/address_book.py @@ -79,14 +79,14 @@ def validate_zip(): # CREATE PYSIMPLEGUI LAYOUT # ------------------------- # Define the columns for the table selector. This will allow entries to be sorted by column! -headings = ss.TableHeadings() -headings.add_column('firstName', 'First name:', 15) -headings.add_column('lastName', 'Last name:', 15) -headings.add_column('city', 'City:', 13) -headings.add_column('fkState', 'State:', 5) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column('firstName', 'First name:', 15) +table_builder.add_column('lastName', 'Last name:', 15) +table_builder.add_column('city', 'City:', 13) +table_builder.add_column('fkState', 'State:', 5) layout = [ - [ss.selector("Addresses", sg.Table, headings=headings, num_rows=10)], + [ss.selector("Addresses", table_builder, num_rows=10)], [ss.field("Addresses.fkGroupName", sg.Combo, size=(30, 10), auto_size_text=False)], [ss.field("Addresses.firstName", label="First name:")], [ss.field("Addresses.lastName", label="Last name:")], diff --git a/examples/SQLite_examples/checkbox_behavior.py b/examples/SQLite_examples/checkbox_behavior.py index 5d95e9b3..4006940c 100644 --- a/examples/SQLite_examples/checkbox_behavior.py +++ b/examples/SQLite_examples/checkbox_behavior.py @@ -32,15 +32,15 @@ # CREATE PYSIMPLEGUI LAYOUT # ------------------------- # Create a table heading object -headings = ss.TableHeadings(allow_cell_edits=True) +table_builder = ss.TableBuilder(allow_cell_edits=True) # Add columns to the table heading -headings.add_column('id', 'id', width=5) +table_builder.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) + table_builder.add_column(col, col, width=8) fields = [] for col in columns: @@ -50,7 +50,7 @@ [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.selector('checkboxes', table_builder, row_height=25)], [ss.actions('checkboxes', edit_protect=False)], fields, ] diff --git a/examples/SQLite_examples/journal_external.py b/examples/SQLite_examples/journal_external.py index 5f60125d..b8e7d5fd 100644 --- a/examples/SQLite_examples/journal_external.py +++ b/examples/SQLite_examples/journal_external.py @@ -12,13 +12,13 @@ # CREATE PYSIMPLEGUI LAYOUT # ------------------------- # Define the columns for the table selector -headings = ss.TableHeadings() -headings.add_column("title", "Title", width=40) -headings.add_column("entry_date", "Date", width=10) -headings.add_column("mood_id", "Mood", width=20) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column("title", "Title", width=40) +table_builder.add_column("entry_date", "Date", width=10) +table_builder.add_column("mood_id", "Mood", width=20) layout = [ - [ss.selector('Journal', sg.Table, key='sel_journal', num_rows=10, headings=headings)], + [ss.selector('Journal', table_builder, key='sel_journal')], [ss.actions('Journal', 'act_journal', edit_protect=False)], [ss.field('Journal.entry_date')], [ss.field('Journal.mood_id', sg.Combo, size=(30, 10), label='My mood:', auto_size_text=False)], diff --git a/examples/SQLite_examples/journal_internal.py b/examples/SQLite_examples/journal_internal.py index cc72db6f..c8f81238 100644 --- a/examples/SQLite_examples/journal_internal.py +++ b/examples/SQLite_examples/journal_internal.py @@ -49,16 +49,18 @@ # CREATE PYSIMPLEGUI LAYOUT # ------------------------- # Define the columns for the table selector using the TableHeading convenience class. -headings = ss.TableHeadings( +table_builder = ss.TableBuilder( + num_rows = 10, sort_enable=True, # Click a header to sort - allow_cell_edits=True # Double-click a cell to make edits + allow_cell_edits=True, # Double-click a cell to make edits + style=ss.TableStyler(row_height=25) ) -headings.add_column('title', 'Title', width=40) -headings.add_column('entry_date', 'Date', width=10) -headings.add_column('mood_id', 'Mood', width=20) +table_builder.add_column('title', 'Title', width=40) +table_builder.add_column('entry_date', 'Date', width=10) +table_builder.add_column('mood_id', 'Mood', width=20) layout = [ - [ss.selector('Journal', sg.Table, num_rows=10, headings=headings, row_height=25)], + [ss.selector('Journal', table_builder)], [ss.actions('Journal')], [ss.field('Journal.entry_date'), sg.CalendarButton( diff --git a/examples/SQLite_examples/journal_with_data_manipulation.py b/examples/SQLite_examples/journal_with_data_manipulation.py index 63fa6f4c..d14ea8d1 100644 --- a/examples/SQLite_examples/journal_with_data_manipulation.py +++ b/examples/SQLite_examples/journal_with_data_manipulation.py @@ -36,13 +36,13 @@ # CREATE PYSIMPLEGUI LAYOUT # ------------------------- # Define the columns for the table selector using the TableHeading convenience class. This will also allow sorting! -headings = ss.TableHeadings() -headings.add_column('title', 'Title', width=40) -headings.add_column('entry_date', 'Date', width=10) -headings.add_column('mood_id', 'Mood', width=20) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column('title', 'Title', width=40) +table_builder.add_column('entry_date', 'Date', width=10) +table_builder.add_column('mood_id', 'Mood', width=20) layout=[ - [ss.selector('Journal', sg.Table, key='sel_journal', num_rows=10, headings=headings)], + [ss.selector('Journal', table_builder, key='sel_journal')], [ss.actions('Journal', 'act_journal', edit_protect=False)], [ss.field('Journal.entry_date')], [ss.field('Journal.mood_id', sg.Combo, size=(30, 10), auto_size_text=False)], diff --git a/examples/SQLite_examples/many_to_many.py b/examples/SQLite_examples/many_to_many.py index d83c5946..62d4e9a1 100644 --- a/examples/SQLite_examples/many_to_many.py +++ b/examples/SQLite_examples/many_to_many.py @@ -60,11 +60,11 @@ [ss.field('Color.name', label_above=True)] ] -headings = ss.TableHeadings(sort_enable=True) -headings.add_column('person_id', 'Person', 18) -headings.add_column('color_id', 'Favorite Color', 18) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column('person_id', 'Person', 18) +table_builder.add_column('color_id', 'Favorite Color', 18) favorites_layout = [ - [ss.selector('FavoriteColor', sg.Table, key='sel_favorite', num_rows=10, headings=headings)], + [ss.selector('FavoriteColor', table_builder, key='sel_favorite')], [ss.actions('act_favorites', 'FavoriteColor', edit_protect=False, search=False)], [ss.field('FavoriteColor.person_id', element=sg.Combo, size=(30, 10), label='Person:', auto_size_text=False)], [ss.field('FavoriteColor.color_id', element=sg.Combo, size=(30, 10), label='Color:', auto_size_text=False)] diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py index 9f8e78c3..82389daf 100644 --- a/examples/SQLite_examples/orders.py +++ b/examples/SQLite_examples/orders.py @@ -29,8 +29,8 @@ # ----------------------------- custom = { "ttk_theme": os_ttktheme, - "marker_sort_asc": " ⬇", - "marker_sort_desc": " ⬆", + "marker_sort_asc": " ⬇ ", + "marker_sort_desc": " ⬆ ", } custom = custom | os_tp ss.themepack(custom) @@ -162,23 +162,38 @@ # fmt: on layout = [[sg.Menu(menu_def, key="-MENUBAR-", font="_ 12")]] -# Define the columns for the table selector using the TableHeading class. -order_heading = ss.TableHeadings( - sort_enable=True, # Click a heading to sort +# Set our universal table options +table_style = ss.TableStyler( + row_height=25, + expand_x=True, + expand_y=True, + frame_pack_kwargs={"expand": True, "fill": "both"}, +) + +# Define the columns for the table selector using the Tabletable class. +order_table = ss.TableBuilder( + num_rows=5, + sort_enable=True, # Click a table to sort allow_cell_edits=True, # Double-click a cell to make edits. # Exempted: Primary Key columns, Generated columns, and columns set as readonly - add_save_heading_button=True, # Click 💾 in sg.Table Heading to trigger DataSet.save_record() apply_search_filter=True, # Filter rows as you type in the search input + lazy_loading=True, # For larger DataSets, inserts slice of rows. See `LazyTable` + add_save_heading_button=True, # Click 💾 in sg.Table Heading to trigger DataSet.save_record() + style=table_style, ) # Add columns -order_heading.add_column(column="order_id", heading_column="ID", width=5) -order_heading.add_column("customer_id", "Customer", 30) -order_heading.add_column("date", "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) +order_table.add_column(column="order_id", heading="ID", width=5) +order_table.add_column("customer_id", "Customer", 30) +order_table.add_column("date", "Date", 20) +order_table.add_column( + column="total", + heading="Total", + width=10, + readonly=True, # set to True to disable editing for individual columns! + col_justify="right", # default, "left". Available: "left", "right", "center" +) +order_table.add_column("completed", "✔", 8) # Layout layout.append( @@ -187,10 +202,7 @@ [ ss.selector( "orders", - sg.Table, - num_rows=5, - headings=order_heading, - row_height=25, + order_table, ) ], [ss.actions("orders")], @@ -198,14 +210,18 @@ ] ) -# order_details TableHeadings: -details_heading = ss.TableHeadings( - sort_enable=True, allow_cell_edits=True, add_save_heading_button=True +# order_details TableBuilder: +details_table = ss.TableBuilder( + num_rows=10, + sort_enable=True, + allow_cell_edits=True, + add_save_heading_button=True, + style=table_style, ) -details_heading.add_column("product_id", "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) +details_table.add_column("product_id", "Product", 30) +details_table.add_column("quantity", "Quantity", 10, col_justify="right") +details_table.add_column("price", "Price/Ea", 10, readonly=True, col_justify="right") +details_table.add_column("subtotal", "Subtotal", 10, readonly=True, col_justify="right") orderdetails_layout = [ [sg.Sizer(h_pixels=0, v_pixels=10)], @@ -217,10 +233,7 @@ [ ss.selector( "order_details", - sg.Table, - num_rows=10, - headings=details_heading, - row_height=25, + details_table, ) ], [ss.actions("order_details", default=False, save=True, insert=True, delete=True)], @@ -244,12 +257,6 @@ 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["order_details:selector"].expand(True, True) -win["order_details:selector"].table_frame.pack(expand=True, fill="both") - # Init pysimplesql Driver and Form # -------------------------------- diff --git a/examples/SQLite_examples/selectors_demo.py b/examples/SQLite_examples/selectors_demo.py index 78a4bb38..20de92fb 100644 --- a/examples/SQLite_examples/selectors_demo.py +++ b/examples/SQLite_examples/selectors_demo.py @@ -35,10 +35,10 @@ """ # PySimpleGUI™ layout code -headings = ss.TableHeadings() -headings.add_column('name', 'Name', width=10) -headings.add_column('example', 'Example', width=40) -headings.add_column('primary_color', 'Primary Color?', width=15) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column('name', 'Name', width=10) +table_builder.add_column('example', 'Example', width=40) +table_builder.add_column('primary_color', 'Primary Color?', width=15) record_columns = [ [ss.field('Colors.name', label='Color name:')], @@ -46,7 +46,7 @@ [ss.field('Colors.primary_color', element=sg.CBox, label='Primary Color?')], ] selectors = [ - [ss.selector('Colors', element=sg.Table, key='tableSelector', headings=headings, num_rows=10)], + [ss.selector('Colors', element=table_builder, key='tableSelector')], [ss.selector('Colors', size=(15, 10), key='selector1')], [ss.selector('Colors', element=sg.Slider, size=(26, 18), key='selector2'), ss.selector('Colors', element=sg.Combo, size=(30, 10), key='selector3')], diff --git a/examples/journal_multiple_databases.py b/examples/journal_multiple_databases.py index 933ba4d9..a5c87846 100644 --- a/examples/journal_multiple_databases.py +++ b/examples/journal_multiple_databases.py @@ -27,14 +27,14 @@ # CREATE PYSIMPLEGUI LAYOUT # ------------------------- # Define the columns for the table selector using the TableHeading convenience class. This will also allow sorting! -headings = ss.TableHeadings(sort_enable=True) -headings.add_column('title', 'Title', width=40) -headings.add_column('entry_date', 'Date', width=10) -headings.add_column('mood_id', 'Mood', width=20) +table_builder = ss.TableBuilder(num_rows=10) +table_builder.add_column('title', 'Title', width=40) +table_builder.add_column('entry_date', 'Date', width=10) +table_builder.add_column('mood_id', 'Mood', width=20) layout = [ [sg.Text('Selected driver: '), sg.Text('', key='driver')], - [ss.selector('Journal', sg.Table, num_rows=10, headings=headings)], + [ss.selector('Journal', table_builder)], [ss.actions('Journal')], [ss.field('Journal.entry_date'), sg.CalendarButton("Select Date", close_when_date_chosen=True, target="Journal.entry_date", # <- target matches field() name @@ -104,5 +104,5 @@ - using Form.field() and Form.selector() functions for easy GUI element creation - using the label keyword argument to Form.record() to define a custom label - using Tables as Form.selector() element types -- Using the TableHeadings() function to define sortable table headings +- Using the TableBuilder() function to define sortable table headings """ diff --git a/examples/orders_multiple_databases.py b/examples/orders_multiple_databases.py index ea056f26..0451cb60 100644 --- a/examples/orders_multiple_databases.py +++ b/examples/orders_multiple_databases.py @@ -404,7 +404,7 @@ def is_valid_email(email): allow_cell_edits=True, # Double-click a cell to make edits. # Exempted: Primary Key columns, Generated columns, and columns set as readonly apply_search_filter=True, # Filter rows as you type in the search input - lazy_loading=True, # For larger DataSets, inserts slice of rows. See `LazyTable` + lazy_loading=True, # For larger DataSets, inserts slice of rows. See `LazyTable` add_save_heading_button=True, # Click 💾 in sg.Table Heading to trigger DataSet.save_record() style=table_style, ) diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index ed7c5d2d..8251550a 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -2820,7 +2820,7 @@ def quick_editor( else: justify = "left" table_builder.add_column( - col, col.capitalize(), width=width, justify=justify + col, col.capitalize(), width=width, col_justify=justify ) layout.append( @@ -7005,6 +7005,57 @@ def selector( return layout +@dc.dataclass +class TableStyler: + # pysimplesql specific + frame_pack_kwargs: Dict[str] = dc.field(default_factory=dict) + + # PySimpleGUI Table kwargs that are compatible with pysimplesql + justification: TableJustify = "left" + row_height: int = None + font: str or Tuple[str, int] or None = None + text_color: str = None + background_color: str = None + alternating_row_color: str = None + selected_row_colors: Tuple[str, str] = (None, None) + header_text_color: str = None + header_background_color: str = None + header_font: str or Tuple[str, int] or None = None + header_border_width: int = None + header_relief: str = None + vertical_scroll_only: bool = True + hide_vertical_scroll: bool = False + border_width: int = None + sbar_trough_color: str = None + sbar_background_color: str = None + sbar_arrow_color: str = None + sbar_width: int = None + sbar_arrow_width: int = None + sbar_frame_color: str = None + sbar_relief: str = None + pad: Union[int, Tuple[int, int], Tuple[Tuple[int, int], Tuple[int, int]]] = None + tooltip: str = None + right_click_menu: List[Union[List[str], str]] = None + expand_x: bool = False + expand_y: bool = False + visible: bool = True + + def __repr__(self): + attrs = self.get_table_kwargs() + return f"TableStyler({attrs})" + + def get_table_kwargs(self): + non_default_attributes = {} + for field in dc.fields(self): + if ( + getattr(self, field.name) != field.default + and getattr(self, field.name) + and field.name not in [] + ): + non_default_attributes[field.name] = getattr(self, field.name) + return non_default_attributes + + @dc.dataclass class TableBuilder(list): @@ -7036,7 +7087,7 @@ class TableBuilder(list): apply_search_filter: bool = False lazy_loading: bool = False add_save_heading_button: bool = False - style: TableStyler = None + style: TableStyler = dc.field(default_factory=TableStyler) _width_map: List[int] = dc.field(default_factory=list, init=False) _col_justify_map: List[int] = dc.field(default_factory=list, init=False) @@ -11249,58 +11300,6 @@ class Driver: msaccess: callable = MSAccess -@dc.dataclass -class TableStyler: - # pysimplesql specific - frame_pack_kwargs: Dict[str] = dc.field(default_factory=dict) - - # PySimpleGUI Table kwargs that are compatible with pysimplesql - justification: TableJustify = "left" - row_height: int = None - font: str or Tuple[str, int] or None = None - text_color: str = None - background_color: str = None - alternating_row_color: str = None - selected_row_colors: Tuple[str, str] = (None, None) - header_text_color: str = None - header_background_color: str = None - header_font: str or Tuple[str, int] or None = None - header_border_width: int = None - header_relief: str = None - vertical_scroll_only: bool = True - hide_vertical_scroll: bool = False - border_width: int = None - sbar_trough_color: str = None - sbar_background_color: str = None - sbar_arrow_color: str = None - sbar_width: int = None - sbar_arrow_width: int = None - sbar_frame_color: str = None - sbar_relief: str = None - enable_click_events: bool = False - pad: Union[int, Tuple[int, int], Tuple[Tuple[int, int], Tuple[int, int]]] = None - tooltip: str = None - right_click_menu: List[Union[List[str], str]] = None - expand_x: bool = False - expand_y: bool = False - visible: bool = True - - def __repr__(self): - attrs = self.get_table_kwargs() - return f"TableStyler({attrs})" - - def get_table_kwargs(self): - non_default_attributes = {} - for field in dc.fields(self): - if ( - getattr(self, field.name) != field.default - and getattr(self, field.name) - and field.name not in [] - ): - non_default_attributes[field.name] = getattr(self, field.name) - return non_default_attributes - - SaveResultsDict = Dict[str, int] CallbacksDict = Dict[str, Callable[[Form, sg.Window], Union[None, bool]]] PromptSaveValue = (