From f609d234c0fc3b280c88bcf4e270124047004283 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 8 Nov 2015 16:07:12 +1000 Subject: [PATCH 01/91] Add the ability to move solids by instance angles --- src/utils.py | 12 ++++++++ src/vmfLib.py | 77 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/utils.py b/src/utils.py index b22946063..d97a00b0b 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1147,6 +1147,18 @@ def cross(self, other): self.x * other.y - self.y * other.x, ) + def localise( + self, + origin: Union['Vec', Vec_tuple], + angles: Union['Vec', Vec_tuple]=None + ): + """Shift this point to be local to the given position and angles + + """ + if angles is not None: + self.rotate(angles.x, angles.y, angles.z) + self.__iadd__(origin) + len = mag mag_sq = len_sq __truediv__ = __div__ diff --git a/src/vmfLib.py b/src/vmfLib.py index 47ae13015..4a74c76a8 100644 --- a/src/vmfLib.py +++ b/src/vmfLib.py @@ -13,7 +13,7 @@ from typing import ( Optional, Union, - Dict, List, Tuple, Iterator, + Dict, List, Tuple, Set, Iterator, ) # Used to set the defaults for versioninfo @@ -88,11 +88,9 @@ def overlay_bounds(over): """Compute the bounding box of an overlay.""" origin = Vec.from_str(over['origin']) return Vec.bbox( - Vec.from_str( - (origin + Vec.from_str(over['uv' + str(x)])) - for x in - range(4) - ) + (origin + Vec.from_str(over['uv' + str(x)])) + for x in + range(4) ) @@ -134,14 +132,14 @@ def __init__( # Allow quick searching for particular groups, without checking # the whole map - self.by_target = defaultdict(CopySet) - self.by_class = defaultdict(CopySet) - - self.entities = [] - self.add_ents(entities or []) # need to set the by_ dicts too. - self.brushes = brushes or [] - self.cameras = cameras or [] - self.cordons = cordons or [] + self.by_target = defaultdict(CopySet) # type: Dict[str, Set[Entity]] + self.by_class = defaultdict(CopySet) # type: Dict[str, Set[Entity]] + + self.entities = [] # type: List[Entity] + self.add_ents(entities or []) # We need to set the by_ dicts too. + self.brushes = brushes or [] # type: List[Solid] + self.cameras = cameras or [] # type: List[Camera] + self.cordons = cordons or [] # type: List[Cordon] self.visgroups = visgroups or [] # mapspawn entity, which is the entity world brushes are saved @@ -795,17 +793,18 @@ def get_origin(self, bbox_min=None, bbox_max=None) -> Vec: """Calculates a vector representing the exact center of this brush.""" if bbox_min is None or bbox_max is None: bbox_min, bbox_max = self.get_bbox() - return (bbox_min+bbox_max)/2 - - def translate(self, diff): - """Move this solid by the specified vector. + return (bbox_min + bbox_max) / 2 - - This does not translate textures as well. - - A tuple can be passed in instead if desired. - """ + def translate(self, diff: Vec): + """Move this solid by the specified vector.""" for s in self.sides: s.translate(diff) + def localise(self, origin: Vec, angles: Vec): + """Shift this brush by the given origin/angles.""" + for s in self.sides: + s.localise(origin, angles) + class UVAxis: """Values saved into Side.uaxis and Side.vaxis. @@ -1086,12 +1085,46 @@ def get_origin(self) -> Vec: def translate(self, diff): """Move this side by the specified vector. - - This does not translate textures as well. - A tuple can be passed in instead if desired. """ for p in self.planes: p += diff + u_axis = Vec(self.uaxis.x, self.uaxis.y, self.uaxis.z) + v_axis = Vec(self.vaxis.x, self.vaxis.y, self.vaxis.z) + + # Fix offset - see source-sdk: utils/vbsp/map.cpp line 2237 + self.uaxis.offset -= diff.dot(u_axis) / self.uaxis.scale + self.vaxis.offset -= diff.dot(v_axis) / self.vaxis.scale + + def localise(self, origin: Vec, angles: Vec=None): + """Shift the face by the given origin and angles. + + This preserves texture offsets + """ + for p in self.planes: + p.localise(origin, angles) + # Rotate the uaxis values + u_axis = Vec(self.uaxis.x, self.uaxis.y, self.uaxis.z) + v_axis = Vec(self.vaxis.x, self.vaxis.y, self.vaxis.z) + + u_axis.rotate(angles.x, angles.y, angles.z) + v_axis.rotate(angles.x, angles.y, angles.z) + + self.uaxis.x, self.uaxis.y, self.uaxis.z = u_axis + self.vaxis.x, self.vaxis.y, self.vaxis.z = v_axis + + # Fix offset - see source-sdk: utils/vbsp/map.cpp line 2237 + self.uaxis.offset -= origin.dot(u_axis) / self.uaxis.scale + self.vaxis.offset -= origin.dot(v_axis) / self.vaxis.scale + + # Keep the values low. The highest texture size in P2 is 1024, so + # do the next power just to be safe. + # Add and subtract 1024 so the value is between -1024, 1024 not 0, 2048 + # (This just looks nicer) + self.uaxis.offset = (self.uaxis.offset + 1024) % 2048 - 1024 + self.vaxis.offset = (self.vaxis.offset + 1024) % 2048 - 1024 + def plane_desc(self): """Return a string which describes this face. From 2447c58b155f84778d666413b3472d47b18f40c1 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 8 Nov 2015 16:07:22 +1000 Subject: [PATCH 02/91] Add a bunch of type hints to utils --- src/utils.py | 75 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/src/utils.py b/src/utils.py index d97a00b0b..1c73b8698 100644 --- a/src/utils.py +++ b/src/utils.py @@ -6,6 +6,12 @@ from sys import platform from enum import Enum +from typing import ( + Union, + Tuple, + SupportsFloat, Iterator, +) + WIN = platform.startswith('win') MAC = platform.startswith('darwin') @@ -274,7 +280,7 @@ def is_plain_text(name, valid_chars=FILE_CHARS): return True -def get_indent(line): +def get_indent(line: str): """Return the whitespace which this line starts with. """ @@ -295,7 +301,7 @@ def con_log(*text): print(*text, flush=True) -def bool_as_int(val): +def bool_as_int(val: bool): """Convert a True/False value into '1' or '0'. Valve uses these strings for True/False in editoritems and other @@ -307,7 +313,7 @@ def bool_as_int(val): return '0' -def conv_bool(val, default=False): +def conv_bool(val: Union[str, int, bool, None], default=False): """Converts a string to a boolean, using a default if it fails. Accepts any of '0', '1', 'false', 'true', 'yes', 'no', 0 and 1, None. @@ -334,7 +340,7 @@ def conv_float(val, default=0.0): return default -def conv_int(val, default=0): +def conv_int(val: str, default=0): """Converts a string to an integer, using a default if it fails. """ @@ -344,7 +350,7 @@ def conv_int(val, default=0): return default -def parse_str(val, x=0.0, y=0.0, z=0.0): +def parse_str(val: str, x=0.0, y=0.0, z=0.0) -> Tuple[int, int, int]: """Convert a string in the form '(4 6 -4)' into a set of floats. If the string is unparsable, this uses the defaults (x,y,z). @@ -370,7 +376,13 @@ def parse_str(val, x=0.0, y=0.0, z=0.0): return x, y, z -def iter_grid(max_x, max_y, min_x=0, min_y=0, stride=1): +def iter_grid( + max_x: int, + max_y: int, + min_x: int=0, + min_y: int=0, + stride: int=1, + ) -> Iterator[Tuple[int, int]]: """Loop over a rectangular grid area.""" for x in range(min_x, max_x, stride): for y in range(min_y, max_y, stride): @@ -445,6 +457,7 @@ def fit(dist, obj): assert sum(items) == orig_dist return list(items) # Dump the deque + class EmptyMapping(abc.Mapping): """A Mapping class which is always empty.""" __slots__ = [] @@ -527,12 +540,11 @@ def __init__(self, x=0.0, y=0.0, z=0.0): except (TypeError, KeyError): self.z = 0.0 - def copy(self): return Vec(self.x, self.y, self.z) @classmethod - def from_str(cls, val, x=0.0, y=0.0, z=0.0): + def from_str(cls, val: str, x=0.0, y=0.0, z=0.0): """Convert a string in the form '(4 6 -4)' into a Vector. If the string is unparsable, this uses the defaults (x,y,z). @@ -553,7 +565,6 @@ def mat_mul(self, matrix): self.x = x*a + y*b + z*c self.y = x*d + y*e + z*f self.z = x*g + y*h + z*i - return self def rotate(self, pitch=0.0, yaw=0.0, roll=0.0, round_vals=True): """Rotate a vector by a Source rotational angle. @@ -628,7 +639,7 @@ def bbox(*points): bbox_max.max(point) return bbox_min, bbox_max - def __add__(self, other) -> 'Vec': + def __add__(self, other: Union['Vec', Vec_tuple, float]) -> 'Vec': """+ operation. This additionally works on scalars (adds to all axes). @@ -709,12 +720,8 @@ def __mul__(self, other) -> 'Vec': return NotImplemented __rmul__ = __mul__ - def __div__(self, other) -> 'Vec': - """Divide the Vector by a scalar. - - If any axis is equal to zero, it will be kept as zero as long - as the magnitude is greater than zero. - """ + def __div__(self, other: float) -> 'Vec': + """Divide the Vector by a scalar.""" if isinstance(other, Vec): return NotImplemented else: @@ -727,7 +734,7 @@ def __div__(self, other) -> 'Vec': except TypeError: return NotImplemented - def __rdiv__(self, other) -> 'Vec': + def __rdiv__(self, other: float) -> 'Vec': """Divide a scalar by a Vector. """ @@ -775,7 +782,7 @@ def __mod__(self, other) -> 'Vec': except TypeError: return NotImplemented - def __divmod__(self, other) -> ('Vec', 'Vec'): + def __divmod__(self, other) -> Tuple['Vec', 'Vec']: """Divide the vector by a scalar, returning the result and remainder. """ @@ -893,7 +900,10 @@ def __bool__(self) -> bool: """Vectors are True if any axis is non-zero.""" return self.x != 0 or self.y != 0 or self.z != 0 - def __eq__(self, other) -> bool: + def __eq__( + self, + other: Union['Vec', abc.Sequence, SupportsFloat], + ) -> bool: """== test. Two Vectors are compared based on the axes. @@ -914,7 +924,10 @@ def __eq__(self, other) -> bool: except ValueError: return NotImplemented - def __lt__(self, other) -> bool: + def __lt__( + self, + other: Union['Vec', abc.Sequence, SupportsFloat], + ) -> bool: """A bool: except ValueError: return NotImplemented - def __le__(self, other) -> bool: + def __le__( + self, + other: Union['Vec', abc.Sequence, SupportsFloat], + ) -> bool: """A<=B test. Two Vectors are compared based on the axes. @@ -964,7 +980,10 @@ def __le__(self, other) -> bool: except ValueError: return NotImplemented - def __gt__(self, other) -> bool: + def __gt__( + self, + other: Union['Vec', abc.Sequence, SupportsFloat], + ) -> bool: """A>B test. Two Vectors are compared based on the axes. @@ -989,7 +1008,7 @@ def __gt__(self, other) -> bool: except ValueError: return NotImplemented - def max(self, other): + def max(self, other: Union['Vec', Vec_tuple]): """Set this vector's values to the maximum of the two vectors.""" if self.x < other.x: self.x = other.x @@ -998,7 +1017,7 @@ def max(self, other): if self.z < other.z: self.z = other.z - def min(self, other): + def min(self, other: Union['Vec', Vec_tuple]): """Set this vector's values to be the minimum of the two vectors.""" if self.x > other.x: self.x = other.x @@ -1054,13 +1073,13 @@ def __repr__(self): """Code required to reproduce this vector.""" return self.__class__.__name__ + "(" + self.join() + ")" - def __iter__(self): + def __iter__(self) -> Iterator[float]: """Allow iterating through the dimensions.""" yield self.x yield self.y yield self.z - def __getitem__(self, ind): + def __getitem__(self, ind: Union[str, int]) -> float: """Allow reading values by index instead of name if desired. This accepts either 0,1,2 or 'x','y','z' to read values. @@ -1075,7 +1094,7 @@ def __getitem__(self, ind): else: return NotImplemented - def __setitem__(self, ind, val): + def __setitem__(self, ind: Union[str, int], val: float): """Allow editing values by index instead of name if desired. This accepts either 0,1,2 or 'x','y','z' to edit values. @@ -1118,7 +1137,7 @@ def __pos__(self): """+ on a Vector simply copies it.""" return Vec(self.x, self.y, self.z) - def norm(self): + def norm(self) -> 'Vec': """Normalise the Vector. This is done by transforming it to have a magnitude of 1 but the same From 403f1848e612a9d524a56bc09960560b56c982d8 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 8 Nov 2015 16:36:17 +1000 Subject: [PATCH 03/91] Add the ability to clone an entity into a different VMF --- src/vmfLib.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/vmfLib.py b/src/vmfLib.py index 4a74c76a8..256c0d796 100644 --- a/src/vmfLib.py +++ b/src/vmfLib.py @@ -674,7 +674,7 @@ def __init__( self.editor = editor or {} self.hidden = hidden - def copy(self, des_id=-1): + def copy(self, des_id=-1, map=None): """Duplicate this brush.""" editor = {} for key in ('color', 'groupid', 'visgroupshown', 'visgroupautoshown'): @@ -682,9 +682,9 @@ def copy(self, des_id=-1): editor[key] = self.editor[key] if 'visgroup' in self.editor: editor['visgroup'] = self.editor['visgroup'][:] - sides = [s.copy() for s in self.sides] + sides = [s.copy(map=VMF) for s in self.sides] return Solid( - self.map, + map or self.map, des_id=des_id, sides=sides, editor=editor, @@ -982,7 +982,7 @@ def parse(vmf_file, tree): tree['smoothing_groups', '0']), ) - def copy(self, des_id=-1): + def copy(self, des_id=-1, map=None): """Duplicate this brush side.""" planes = [p.as_tuple() for p in self.planes] if self.is_disp: @@ -996,7 +996,7 @@ def copy(self, des_id=-1): disp_data = None return Side( - self.map, + map or self.map, planes=planes, des_id=des_id, mat=self.mat, @@ -1211,11 +1211,11 @@ def copy(self, des_id=-1): new_editor[key] = value new_editor['visgroup'] = self.editor['visgroup'][:] - new_solids = [s.copy() for s in self.solids] + new_solids = [s.copy(map=map) for s in self.solids] outs = [o.copy() for o in self.outputs] return Entity( - vmf_file=self.map, + vmf_file=map or self.map, keys=new_keys, fixup=new_fixup, ent_id=des_id, From 882f0be7e901abc70f52269b3bf0a05fdd02bdf0 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 8 Nov 2015 16:38:44 +1000 Subject: [PATCH 04/91] Add a bunch of type hints --- src/vmfLib.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/vmfLib.py b/src/vmfLib.py index 256c0d796..1cf3caf27 100644 --- a/src/vmfLib.py +++ b/src/vmfLib.py @@ -228,7 +228,7 @@ def create_ent(self, **kargs) -> 'Entity': return ent @staticmethod - def parse(tree): + def parse(tree: Union[Property, str]): """Convert a property_parser tree into VMF classes. """ if not isinstance(tree, Property): @@ -608,7 +608,14 @@ def export(self, buffer, ind=''): class Cordon: """Represents one cordon volume.""" - def __init__(self, vmf_file, min_, max_, is_active=True, name='Cordon'): + def __init__( + self, + vmf_file: VMF, + min_: Vec, + max_: Vec, + is_active=True, + name='Cordon', + ): self.map = vmf_file self.name = name self.bounds_min = min_ @@ -669,7 +676,7 @@ def __init__( hidden=False, ): self.map = vmf_file - self.sides = sides or [] + self.sides = sides or [] # type: List[Side] self.id = vmf_file.solid_id.get_id(des_id) self.editor = editor or {} self.hidden = hidden @@ -1183,8 +1190,8 @@ def __init__( self.map = vmf_file self.keys = keys or {} self.fixup = EntityFixup(fixup or {}) - self.outputs = outputs or [] - self.solids = solids or [] + self.outputs = outputs or [] # type: List[Output] + self.solids = solids or [] # type: List[Solid] self.id = vmf_file.ent_id.get_id(ent_id) self.hidden = hidden self.editor = editor or {'visgroup': []} @@ -1198,7 +1205,7 @@ def __init__( if 'color' not in self.editor: self.editor['color'] = '255 255 255' - def copy(self, des_id=-1): + def copy(self, des_id=-1, map=None): """Duplicate this entity entirely, including solids and outputs.""" new_keys = {} new_fixup = self.fixup.copy_dict() @@ -1226,7 +1233,7 @@ def copy(self, des_id=-1): ) @staticmethod - def parse(vmf_file, tree_list, hidden=False): + def parse(vmf_file, tree_list: Property, hidden=False): """Parse a property tree into an Entity object.""" ent_id = -1 solids = [] @@ -1523,6 +1530,7 @@ def get_origin(self): FixupTuple = namedtuple('FixupTuple', 'var value id') + class EntityFixup: """A speciallised mapping which keeps track of the variable indexes. From 5c70c103900d8fd0d4106f85c52fa48f3696d8c6 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 8 Nov 2015 17:44:59 +1000 Subject: [PATCH 05/91] Load in template objects --- src/packageLoader.py | 72 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/packageLoader.py b/src/packageLoader.py index 1cc0995a6..9119de9ff 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -11,20 +11,11 @@ from FakeZip import FakeZip, zip_names from selectorWin import SelitemData from loadScreen import main_loader as loader +import vmfLib as VLib import extract_packages import utils -__all__ = [ - 'load_packages', - 'Style', - 'Item', - 'QuotePack', - 'Skybox', - 'Music', - 'StyleVar', - ] - all_obj = {} obj_override = {} packages = {} @@ -34,6 +25,8 @@ res_count = -1 +TEMPLATE_FILE = VLib.VMF() + ObjData = namedtuple('ObjData', 'zip_file, info_block, pak_id, disp_name') ParseData = namedtuple('ParseData', 'zip_file, id, info, pak_id') PackageData = namedtuple('package_data', 'zip_file, info, name, disp_name') @@ -79,7 +72,14 @@ def reraise_keyerror(err, obj_id): ) from err -def get_config(prop_block, zip_file, folder, pak_id='', prop_name='config'): +def get_config( + prop_block, + zip_file, + folder, + pak_id='', + prop_name='config', + extension='.cfg', + ): """Extract a config file refered to by the given property block. Looks for the prop_name key in the given prop_block. @@ -96,7 +96,10 @@ def get_config(prop_block, zip_file, folder, pak_id='', prop_name='config'): if prop_block.value == '': return Property(None, []) - path = os.path.join(folder, prop_block.value) + '.cfg' + path = os.path.join(folder, prop_block.value) + if len(path) < 3 or path[-4] != '.': + # Add extension + path += extension try: with zip_file.open(path) as f: return Property.parse(f, @@ -1038,6 +1041,51 @@ def parse(cls, data): ) +@pak_object('BrushTemplate', has_img=False) +class BrushTemplate: + """A template brush which will be copied into the map, then retextured. + + This allows the sides of the brush to swap between wall/floor textures + based on orientation. + All brushes from the given VMF will be copied - entities will be ignored. + """ + def __init__(self, temp_id, vmf_file: VLib.VMF): + self.id = temp_id + # We don't actually store the solids here - put them in + # the TEMPLATE_FILE VMF with a custom classname. + # That way the VMF object can vanish. + self.template = TEMPLATE_FILE.create_ent( + classname='bee2_template_world', + template_id=self.id, + ) + self.template.solids = [ + solid.copy(map=TEMPLATE_FILE) + for solid in + vmf_file.brushes + ] + for ent in vmf_file.entities: + self.template.solids.extend( + solid.copy(map=TEMPLATE_FILE) + for solid in + ent.solids + ) + + @classmethod + def parse(cls, data: ParseData): + file = get_config( + prop_block=data.info, + zip_file=data.zip_file, + folder='templates', + pak_id=data.pak_id, + prop_name='file', + ) + file = VLib.VMF.parse(file) + template = cls( + data.id, + file, + ) + + def desc_parse(info): """Parse the description blocks, to create data which matches richTextBox. From cf5f3652c48397c9288c7fc8beb51efbbc10a81d Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 8 Nov 2015 18:02:44 +1000 Subject: [PATCH 06/91] Remember if a brush is a detail or world brush. --- src/packageLoader.py | 41 +++++++++++++++++++++++++---------------- src/vmfLib.py | 2 +- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/packageLoader.py b/src/packageLoader.py index 9119de9ff..b042d309c 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -1047,28 +1047,37 @@ class BrushTemplate: This allows the sides of the brush to swap between wall/floor textures based on orientation. - All brushes from the given VMF will be copied - entities will be ignored. + All world and detail brushes from the given VMF will be copied. """ def __init__(self, temp_id, vmf_file: VLib.VMF): self.id = temp_id # We don't actually store the solids here - put them in - # the TEMPLATE_FILE VMF with a custom classname. - # That way the VMF object can vanish. - self.template = TEMPLATE_FILE.create_ent( - classname='bee2_template_world', - template_id=self.id, - ) - self.template.solids = [ - solid.copy(map=TEMPLATE_FILE) - for solid in - vmf_file.brushes - ] - for ent in vmf_file.entities: - self.template.solids.extend( + # the TEMPLATE_FILE VMF. That way the VMF object can vanish. + if vmf_file.brushes: + self.temp_world = TEMPLATE_FILE.create_ent( + classname='bee2_template_world', + template_id=self.id, + ) + self.temp_world.solids = [ solid.copy(map=TEMPLATE_FILE) for solid in - ent.solids + vmf_file.brushes + ] + else: + self.temp_world = None + if any(e.is_brush() for e in vmf_file.by_class['func_detail']): + self.temp_detail = TEMPLATE_FILE.create_ent( + classname='bee2_template_detail', + template_id=self.id, ) + for ent in vmf_file.by_class['func_detail']: + self.temp_detail.solids.extend( + solid.copy(map=TEMPLATE_FILE) + for solid in + ent.solids + ) + else: + self.temp_detail = None @classmethod def parse(cls, data: ParseData): @@ -1080,7 +1089,7 @@ def parse(cls, data: ParseData): prop_name='file', ) file = VLib.VMF.parse(file) - template = cls( + return cls( data.id, file, ) diff --git a/src/vmfLib.py b/src/vmfLib.py index 1cf3caf27..9194cbc5a 100644 --- a/src/vmfLib.py +++ b/src/vmfLib.py @@ -689,7 +689,7 @@ def copy(self, des_id=-1, map=None): editor[key] = self.editor[key] if 'visgroup' in self.editor: editor['visgroup'] = self.editor['visgroup'][:] - sides = [s.copy(map=VMF) for s in self.sides] + sides = [s.copy(map=map) for s in self.sides] return Solid( map or self.map, des_id=des_id, From fad81826fe29e9ebcc70527081f5023a72334bd8 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 11:31:13 +1000 Subject: [PATCH 07/91] Template importing logic --- src/conditions.py | 189 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 160 insertions(+), 29 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index 6deed4a33..92cc4d581 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -1,6 +1,6 @@ # coding: utf-8 from decimal import Decimal -from collections import namedtuple +from collections import namedtuple, defaultdict from enum import Enum import random import math @@ -11,13 +11,18 @@ import vmfLib as VLib import utils +from typing import ( + Union, Optional, + Dict, List, Tuple +) + # Stuff we get from VBSP in init() GLOBAL_INSTANCES = set() OPTIONS = {} ALL_INST = set() STYLE_VARS = {} VOICE_ATTR = {} -VMF = None +VMF = None # type: VLib.VMF conditions = [] FLAG_LOOKUP = {} @@ -34,6 +39,18 @@ GOO_LOCS = set() # A set of all goo solid origins. +# A VMF containing template brushes, which will be loaded in and retextured +# The first list are world brushes, the second are func_detail brushes. +TEMPLATES = {} # type: Dict[str, Tuple[List[VLib.Solid], List[VLib.Solid]]] +TEMPLATE_LOCATION = 'bee2/templates.vmf' + +class TEMP_TYPES(Enum): + """Value used for import_template()'s force_type parameter. + """ + default = 0 + world = 1 + detail = 2 + class MAT_TYPES(Enum): """The values saved in the solidGroup.color attribute.""" @@ -113,6 +130,33 @@ def __str__(self): del xp, xn, yp, yn, zp, zn +B = MAT_TYPES.black +W = MAT_TYPES.white +TEMPLATE_RETEXTURE = { + # textures map -> surface types for template brushes. + # It's mainly for grid size and colour - floor/ceiling textures + # will be used instead at those orientations + + 'metal/black_wall_metal_002c': (B, 'wall'), + 'metal/black_wall_metal_002a': (B, '2x2'), + 'metal/black_wall_metal_002b': (B, '4x4'), + + 'tile/white_wall_tile001a': (W, 'wall'), + 'tile/white_wall_state': (W, '2x2'), + 'tile/white_wall_tile003f': (W, '4x4'), + + # No black portal-placement texture + 'metal/black_floor_metal_bullseye_001': 'black.special', + 'tile/white_wall_tile003j': 'white.special', + + 'anim_wp/framework/backpanels': 'special.behind', + 'anim_wp/framework/squarebeams': 'special.edge', + 'glass/glasswindow007a_less_shiny': 'special.glass', + 'metal/metalgrate018': 'special.grating', +} + +del B, W + class NextInstance(Exception): """Raised to skip to the next instance, from the SkipInstance result.""" @@ -603,6 +647,120 @@ def set_ent_keys(ent, inst, prop_block, suffix=''): ent[prop.real_name] = name + val +def load_templates(): + """Load in the template file, used for import_template().""" + with open(TEMPLATE_LOCATION) as file: + props = Property.parse(file, TEMPLATE_LOCATION) + vmf = VLib.VMF(props) + detail_ents = defaultdict(list) + world_ents = defaultdict(list) + for ent in vmf.by_class['bee2_template_world']: + world_ents[ent['template_id']].extend(ent.solids) + + for ent in vmf.by_class['bee2_template_detail']: + detail_ents[ent['template_id']].extend(ent.solids) + + for temp_id in set(detail_ents.keys()).union(world_ents.keys()): + TEMPLATES[temp_id.casefold()] = ( + world_ents[temp_id], + detail_ents[temp_id], + ) + + +def import_template( + temp_name, + origin, + angles, + force_type=TEMP_TYPES.default, + ) -> Tuple[ + List[VLib.Solid], + Optional[VLib.Entity], + ]: + """Import the given template at a location. + + If force_type is set to 'detail' or 'world', all brushes will be converted + to the specified type instead. A list of world brushes and the func_detail + entity will be returned. If there are no detail brushes, None will be + returned instead of an invalid entity. + """ + orig_world, orig_detail = TEMPLATES[temp_name.casefold()] + new_world = [] + new_detail = [] + + for orig_list, new_list in [ + (orig_world, new_world), + (orig_detail, new_detail) + ]: + for old_brush in orig_list: + brush = old_brush.copy(map=VMF) + brush.localise(origin, angles) + + if force_type is TEMP_TYPES.detail: + new_detail.extend(new_world) + new_world.clear() + elif force_type is TEMP_TYPES.world: + new_world.extend(new_detail) + new_detail.clear() + + VMF.add_brushes(new_world) + + if new_detail: + detail_ent = VMF.create_ent( + classname='func_detail' + ) + detail_ent.solids = new_detail + else: + detail_ent = None + + return new_world, detail_ent + + +def retexture_template( + world: List[VLib.Solid], + detail: VLib.Entity, + origin: Vec, + ): + """Retexture a template at the given location. + + - Only textures in the TEMPLATE_RETEXTURE dict will be replaced. + - Others will be ignored (nodraw, plasticwall, etc) + - Wall textures pointing up and down will switch to floor/ceiling textures. + - Textures of the same type, normal and inst origin will randomise to the + same type. + """ + import vbsp + all_brushes = list(world) + if detail is not None: + all_brushes.extend(detail.solids) + rand_prefix = 'TEMPLATE_{}_{}_{}:'.format(*origin) + + for brush in all_brushes: + for face in brush: + tex_type = TEMPLATE_RETEXTURE.get(face.mat.casefold()) + if tex_type is None: + continue + + norm = face.normal() + random.seed(rand_prefix + norm.join('_')) + + if isinstance(tex_type, str): + # It's something like squarebeams or backpanels, just look + # it up + face.mat = vbsp.get_tex(tex_type) + continue + # It's a regular wall type! + tex_colour, grid_size = tex_type + + # Floor/ceiling is always 1 size! + if norm == (0, 0, 1): + grid_size = 'floor' + elif norm == (0, 0, -1): + grid_size = 'ceiling' + face.mat = vbsp.get_tex( + '{!s}.{!s}'.format(tex_colour, grid_size) + ) + + @make_flag('debug') def debug_flag(inst, props): """Displays text when executed, for debugging conditions. @@ -2547,9 +2705,6 @@ def res_add_brush(inst, res): ) tex_type = 'black' - # We need to rescale black walls and ceilings - rescale = vbsp.get_bool_opt('random_blackwall_scale') and tex_type == 'black' - dim = point2 - point1 dim.max(-dim) @@ -2560,23 +2715,17 @@ def res_add_brush(inst, res): y_maxsize = min(dim.x, dim.z) if x_maxsize <= 32: x_grid = '4x4' - x_scale = 0.25 elif x_maxsize <= 64: x_grid = '2x2' - x_scale = 0.5 else: x_grid = 'wall' - x_scale = 1 if y_maxsize <= 32: y_grid = '4x4' - y_scale = 0.25 elif y_maxsize <= 64: y_grid = '2x2' - y_scale = 0.5 else: y_grid = 'wall' - y_scale = 1 grid_offset = (origin // 128) @@ -2596,24 +2745,6 @@ def res_add_brush(inst, res): solids.top.mat = vbsp.get_tex(tex_type + '.floor') solids.bottom.mat = vbsp.get_tex(tex_type + '.ceiling') - if rescale: - z_maxsize = min(dim.x, dim.y) - # randomised black wall scale applies to the ceiling too - if z_maxsize <= 32: - z_scale = 0.25 - elif z_maxsize <= 64: - z_scale = random.choice((0.5, 0.5, 0.25)) - else: - z_scale = random.choice((1, 1, 0.5, 0.5, 0.25)) - else: - z_scale = 0.25 - - if rescale: - solids.north.scale = y_scale - solids.south.scale = y_scale - solids.east.scale = x_scale - solids.west.scale = x_scale - solids.bottom.scale = z_scale if utils.conv_bool(res['detail', False], False): # Add the brush to a func_detail entity From 4e56ec40965f251d856f21f8d0bd4b3eac5e71a2 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 12:47:53 +1000 Subject: [PATCH 08/91] Export template VMF --- src/gameMan.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/gameMan.py b/src/gameMan.py index 4bef51b9c..75b9f18ff 100644 --- a/src/gameMan.py +++ b/src/gameMan.py @@ -20,6 +20,7 @@ import utils import UI import loadScreen +import packageLoader import extract_packages all_games = [] @@ -305,8 +306,8 @@ def export( export_screen.set_length( 'CONF', # VBSP_conf, Editoritems, instances, gameinfo, pack_lists, - # editor_sounds - 6 + + # editor_sounds, template VMF + 7 + # Don't add the voicelines to the progress bar if not selected (0 if voice is None else len(VOICE_PATHS)), ) @@ -523,6 +524,11 @@ def export( self.add_editor_sounds(editor_sounds.values()) export_screen.step('CONF') + print('Writing template VMF!') + with open(self.abs_path('bin/bee2/templates.vmf'), 'w') as temp_file: + packageLoader.TEMPLATE_FILE.export(temp_file) + export_screen.step('CONF') + if voice is not None: for prefix, dest, pretty in VOICE_PATHS: path = os.path.join( From e003d4c3d185de1eea2e958b82ebb26cf14f0a60 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 12:47:59 +1000 Subject: [PATCH 09/91] Formatting --- src/vbsp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vbsp.py b/src/vbsp.py index b67f520b4..b47b5fbd5 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -2387,7 +2387,7 @@ def main(): ) fix_inst() - alter_flip_panel() # Must be done before conditions! + alter_flip_panel() # Must be done before conditions! conditions.check_all() add_extra_ents(mode=GAME_MODE) From e858b36f9bc6586602df8a31a3281b8bd4f92797 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 12:48:39 +1000 Subject: [PATCH 10/91] Fix bugs with import_template(), and add corresponding condition --- src/conditions.py | 62 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index 92cc4d581..ac12d7b95 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -12,9 +12,9 @@ import utils from typing import ( - Union, Optional, - Dict, List, Tuple -) + Optional, + Dict, List, Tuple, + ) # Stuff we get from VBSP in init() GLOBAL_INSTANCES = set() @@ -142,12 +142,14 @@ def __str__(self): 'metal/black_wall_metal_002b': (B, '4x4'), 'tile/white_wall_tile001a': (W, 'wall'), + 'tile/white_wall_tile003a': (W, 'wall'), 'tile/white_wall_state': (W, '2x2'), 'tile/white_wall_tile003f': (W, '4x4'), - # No black portal-placement texture - 'metal/black_floor_metal_bullseye_001': 'black.special', - 'tile/white_wall_tile003j': 'white.special', + # No black portal-placement texture, so use the bullseye instead + 'metal/black_floor_metal_bullseye_001': (B, 'special'), + 'tile/white_wall_tile003j': (W, 'special'), + 'tile/white_wall_tile_bullseye': (W, 'special'), # For symmetry 'anim_wp/framework/backpanels': 'special.behind', 'anim_wp/framework/squarebeams': 'special.edge', @@ -400,6 +402,7 @@ def init(seed, inst_list, vmf_file): conditions.sort() build_solid_dict() + load_templates() def check_all(): @@ -651,7 +654,7 @@ def load_templates(): """Load in the template file, used for import_template().""" with open(TEMPLATE_LOCATION) as file: props = Property.parse(file, TEMPLATE_LOCATION) - vmf = VLib.VMF(props) + vmf = VLib.VMF.parse(props) detail_ents = defaultdict(list) world_ents = defaultdict(list) for ent in vmf.by_class['bee2_template_world']: @@ -683,7 +686,7 @@ def import_template( entity will be returned. If there are no detail brushes, None will be returned instead of an invalid entity. """ - orig_world, orig_detail = TEMPLATES[temp_name.casefold()] + orig_world, orig_detail = TEMPLATES[temp_name] new_world = [] new_detail = [] @@ -694,6 +697,7 @@ def import_template( for old_brush in orig_list: brush = old_brush.copy(map=VMF) brush.localise(origin, angles) + new_list.append(brush) if force_type is TEMP_TYPES.detail: new_detail.extend(new_world) @@ -719,6 +723,7 @@ def retexture_template( world: List[VLib.Solid], detail: VLib.Entity, origin: Vec, + replace_tex: dict=utils.EmptyMapping, ): """Retexture a template at the given location. @@ -727,6 +732,8 @@ def retexture_template( - Wall textures pointing up and down will switch to floor/ceiling textures. - Textures of the same type, normal and inst origin will randomise to the same type. + - replace_tex is a replacer for textures, applied if the material is not + in TEMPLATE_RETEXTURE. """ import vbsp all_brushes = list(world) @@ -738,6 +745,7 @@ def retexture_template( for face in brush: tex_type = TEMPLATE_RETEXTURE.get(face.mat.casefold()) if tex_type is None: + face.mat = replace_tex.get(face.mat.casefold(), face.mat) continue norm = face.normal() @@ -751,6 +759,21 @@ def retexture_template( # It's a regular wall type! tex_colour, grid_size = tex_type + if grid_size == 'special': + # Various fallbacks if not defines + face.mat = vbsp.get_tex( + 'special.{!s}_wall'.format(tex_colour) + ) + if face.mat == '': + face.mat = vbsp.get_tex( + 'special.{!s}'.format(tex_colour) + ) + if face.mat == '': + grid_size = 'wall' + # No special texture, use wall texture + else: + continue + # Floor/ceiling is always 1 size! if norm == (0, 0, 1): grid_size = 'floor' @@ -2745,7 +2768,6 @@ def res_add_brush(inst, res): solids.top.mat = vbsp.get_tex(tex_type + '.floor') solids.bottom.mat = vbsp.get_tex(tex_type + '.ceiling') - if utils.conv_bool(res['detail', False], False): # Add the brush to a func_detail entity VMF.create_ent( @@ -2758,6 +2780,28 @@ def res_add_brush(inst, res): VMF.add_brush(solids.solid) +@make_result_setup('TemplateBrush') +def res_import_template_setup(res): + temp_id = res['id'].casefold() + replace_tex = { + prop.name: prop.value + for prop in + res.find_key('replace', []) + } + return temp_id, replace_tex + + +@make_result('TemplateBrush') +def res_import_template(inst, res): + temp_id, replace_tex = res.value + if temp_id not in TEMPLATES: + return + origin = Vec.from_str(inst['origin']) + angles = Vec.from_str(inst['angles', '0 0 0']) + world, detail = import_template(temp_id, origin, angles) + retexture_template(world, detail, origin) + + def scaff_scan(inst_list, start_ent): """Given the start item and instance list, follow the programmed path.""" cur_ent = start_ent From a6b898c69464be9e421fc6b20f7320f2f336a2f7 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 14:58:21 +1000 Subject: [PATCH 11/91] Rotate axis-aligned faces to consitent orientations --- src/conditions.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index ac12d7b95..db31a096d 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -148,7 +148,7 @@ def __str__(self): # No black portal-placement texture, so use the bullseye instead 'metal/black_floor_metal_bullseye_001': (B, 'special'), - 'tile/white_wall_tile003j': (W, 'special'), + 'tile/white_wall_tile004j': (W, 'special'), 'tile/white_wall_tile_bullseye': (W, 'special'), # For symmetry 'anim_wp/framework/backpanels': 'special.behind', @@ -774,11 +774,28 @@ def retexture_template( else: continue - # Floor/ceiling is always 1 size! - if norm == (0, 0, 1): - grid_size = 'floor' - elif norm == (0, 0, -1): - grid_size = 'ceiling' + if 1 in norm or -1 in norm: + # If axis-aligned, make the orientation aligned to world + # That way multiple items merge well, and walls are upright + face.offset = 0 + + # Floor / ceiling is always 1 size - 4x4 + if norm == (0, 0, 1): + grid_size = 'ceiling' + face.uaxis = VLib.UVAxis(1, 0, 0) + face.vaxis = VLib.UVAxis(0, -1, 0) + elif norm == (0, 0, -1): + grid_size = 'floor' + face.uaxis = VLib.UVAxis(1, 0, 0) + face.vaxis = VLib.UVAxis(0, -1, 0) + # Walls: + elif norm == (-1, 0, 0) or norm == (1, 0, 0): + face.uaxis = VLib.UVAxis(0, 1, 0) + face.vaxis = VLib.UVAxis(0, 0, -1) + elif norm == (0, -1, 0) or norm == (0, 1, 0): + face.uaxis=VLib.UVAxis(1, 0, 0) + face.vaxis=VLib.UVAxis(0, 0, -1) + face.mat = vbsp.get_tex( '{!s}.{!s}'.format(tex_colour, grid_size) ) From a92aeec84cf69fba68300ee5e89806cd6d1c1452 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 16:16:44 +1000 Subject: [PATCH 12/91] Add ability to force a particluar colour for templates - Use to avoid having black/white values constantly --- src/conditions.py | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index db31a096d..2d29b5475 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -724,6 +724,7 @@ def retexture_template( detail: VLib.Entity, origin: Vec, replace_tex: dict=utils.EmptyMapping, + force_colour: MAT_TYPES=None, ): """Retexture a template at the given location. @@ -732,8 +733,8 @@ def retexture_template( - Wall textures pointing up and down will switch to floor/ceiling textures. - Textures of the same type, normal and inst origin will randomise to the same type. - - replace_tex is a replacer for textures, applied if the material is not - in TEMPLATE_RETEXTURE. + - replace_tex is a replacement table. This overrides everything else. + - If force_colour is set, all tile textures will be switched accordingly. """ import vbsp all_brushes = list(world) @@ -743,11 +744,17 @@ def retexture_template( for brush in all_brushes: for face in brush: - tex_type = TEMPLATE_RETEXTURE.get(face.mat.casefold()) - if tex_type is None: - face.mat = replace_tex.get(face.mat.casefold(), face.mat) + folded_mat = face.mat.casefold() + if folded_mat in replace_tex: + # replace_tex overrides everything + face.mat = replace_tex[folded_mat] continue + tex_type = TEMPLATE_RETEXTURE.get(folded_mat) + + if tex_type is None: + continue # It's nodraw, or something we shouldn't change + norm = face.normal() random.seed(rand_prefix + norm.join('_')) @@ -759,6 +766,9 @@ def retexture_template( # It's a regular wall type! tex_colour, grid_size = tex_type + if force_colour is not None: + tex_colour = force_colour + if grid_size == 'special': # Various fallbacks if not defines face.mat = vbsp.get_tex( @@ -793,8 +803,8 @@ def retexture_template( face.uaxis = VLib.UVAxis(0, 1, 0) face.vaxis = VLib.UVAxis(0, 0, -1) elif norm == (0, -1, 0) or norm == (0, 1, 0): - face.uaxis=VLib.UVAxis(1, 0, 0) - face.vaxis=VLib.UVAxis(0, 0, -1) + face.uaxis = VLib.UVAxis(1, 0, 0) + face.vaxis = VLib.UVAxis(0, 0, -1) face.mat = vbsp.get_tex( '{!s}.{!s}'.format(tex_colour, grid_size) @@ -2800,23 +2810,38 @@ def res_add_brush(inst, res): @make_result_setup('TemplateBrush') def res_import_template_setup(res): temp_id = res['id'].casefold() + + force_colour = res['force', 'none'].casefold() + if force_colour == 'white': + force_colour = MAT_TYPES.white + elif force_colour == 'black': + force_colour = MAT_TYPES.black + else: + force_colour = None + replace_tex = { prop.name: prop.value for prop in res.find_key('replace', []) } - return temp_id, replace_tex + return temp_id, replace_tex, force_colour @make_result('TemplateBrush') def res_import_template(inst, res): - temp_id, replace_tex = res.value + temp_id, replace_tex, force_colour = res.value if temp_id not in TEMPLATES: return origin = Vec.from_str(inst['origin']) angles = Vec.from_str(inst['angles', '0 0 0']) world, detail = import_template(temp_id, origin, angles) - retexture_template(world, detail, origin) + retexture_template( + world, + detail, + origin, + replace_tex, + force_colour, + ) def scaff_scan(inst_list, start_ent): From 789221f6b15ad3e81b97c72c3ae504fd9fec0be4 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 17:05:42 +1000 Subject: [PATCH 13/91] Add an actual 1x1 white tile to the list --- src/conditions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/conditions.py b/src/conditions.py index 2d29b5475..7fd6fff0d 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -143,6 +143,7 @@ def __str__(self): 'tile/white_wall_tile001a': (W, 'wall'), 'tile/white_wall_tile003a': (W, 'wall'), + 'tile/white_wall_tile003h': (W, 'wall'), 'tile/white_wall_state': (W, '2x2'), 'tile/white_wall_tile003f': (W, '4x4'), From f6be22dd2973e7237ea833cd115616142bc34f7f Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 9 Nov 2015 17:34:45 +1000 Subject: [PATCH 14/91] Add some additional white tiles to the list --- src/conditions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/conditions.py b/src/conditions.py index 7fd6fff0d..c86cd982c 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -143,6 +143,8 @@ def __str__(self): 'tile/white_wall_tile001a': (W, 'wall'), 'tile/white_wall_tile003a': (W, 'wall'), + 'tile/white_wall_tile003b': (W, 'wall'), + 'tile/white_wall_tile003c': (W, '2x2'), 'tile/white_wall_tile003h': (W, 'wall'), 'tile/white_wall_state': (W, '2x2'), 'tile/white_wall_tile003f': (W, '4x4'), From 7488a3705e0648f763496457a7768c4a5e521071 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Wed, 11 Nov 2015 12:42:42 +1000 Subject: [PATCH 15/91] Fix elsecondition --- src/conditions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/conditions.py b/src/conditions.py index 6deed4a33..01692a34b 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -168,6 +168,7 @@ def parse(cls, prop_block): # Shortcut to eliminate lots of Result - Condition pairs results.append(prop) elif prop.name == 'elsecondition': + prop.name = 'condition' else_results.append(prop) elif prop.name == 'priority': try: From f95fb7e1a5c9baa37006d4828e0b660048647eeb Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Wed, 11 Nov 2015 12:58:26 +1000 Subject: [PATCH 16/91] Add the ability to override the grid type of a texture --- src/conditions.py | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index c86cd982c..53b5143ac 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -728,6 +728,7 @@ def retexture_template( origin: Vec, replace_tex: dict=utils.EmptyMapping, force_colour: MAT_TYPES=None, + force_grid:str=None, ): """Retexture a template at the given location. @@ -738,6 +739,8 @@ def retexture_template( same type. - replace_tex is a replacement table. This overrides everything else. - If force_colour is set, all tile textures will be switched accordingly. + - If force_grid is set, all tile textures will be that size + ('wall', '2x2', '4x4', 'special') """ import vbsp all_brushes = list(world) @@ -771,6 +774,8 @@ def retexture_template( if force_colour is not None: tex_colour = force_colour + if force_grid is not None: + grid_size = force_grid if grid_size == 'special': # Various fallbacks if not defines @@ -2814,27 +2819,53 @@ def res_add_brush(inst, res): def res_import_template_setup(res): temp_id = res['id'].casefold() - force_colour = res['force', 'none'].casefold() - if force_colour == 'white': + force = res['force', 'none'].casefold().split() + if 'white' in force: force_colour = MAT_TYPES.white - elif force_colour == 'black': + elif 'black' in force: force_colour = MAT_TYPES.black else: force_colour = None + for size in ('2x2', '4x4', 'wall', 'special') : + if size in force: + force_grid = size + break + else: + force_grid = None + replace_tex = { prop.name: prop.value for prop in res.find_key('replace', []) } - return temp_id, replace_tex, force_colour + return temp_id, replace_tex, force_colour, force_grid @make_result('TemplateBrush') def res_import_template(inst, res): - temp_id, replace_tex, force_colour = res.value + """Import a template VMF file, retexturing it to match orientatation. + + It will be placed overlapping the given instance. + Options: + - ID: The ID of the template to be inserted. + - force: a space-seperated list of overrides. If 'white' or 'black' is + present, the colour of tiles will be overriden. If a tile size + ('2x2', '4x4', 'wall', 'special') is included, all tiles will + be switched to that size (if not a floor/ceiling) + - replace: A block of template material -> replacement textures. + This is case insensitive - any texture here will not be altered + otherwise. + """ + temp_id, replace_tex, force_colour, force_grid = res.value + if temp_id not in TEMPLATES: + # The template map is read in after setup is performed, so + # it must be checked here! + # We don't want an error, just quit + utils.con_log('"{}" not a valid template!'.format(temp_id)) return + origin = Vec.from_str(inst['origin']) angles = Vec.from_str(inst['angles', '0 0 0']) world, detail = import_template(temp_id, origin, angles) @@ -2844,6 +2875,7 @@ def res_import_template(inst, res): origin, replace_tex, force_colour, + force_grid, ) From a1f995c6a4cb393eb8926cdb289988d2a71f12fe Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Wed, 11 Nov 2015 15:30:45 +1000 Subject: [PATCH 17/91] Add ability to force a grid size for templates, and tweak textures --- src/conditions.py | 116 +++++++++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index 53b5143ac..6d631f3e8 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -13,7 +13,7 @@ from typing import ( Optional, - Dict, List, Tuple, + Dict, List, Tuple, NamedTuple ) # Stuff we get from VBSP in init() @@ -34,9 +34,6 @@ ALL_RESULTS = [] ALL_META = [] -SOLIDS = {} # A dictionary mapping origins to their brushes -solidGroup = namedtuple('solidGroup', 'face solid normal color') - GOO_LOCS = set() # A set of all goo solid origins. # A VMF containing template brushes, which will be loaded in and retextured @@ -44,6 +41,7 @@ TEMPLATES = {} # type: Dict[str, Tuple[List[VLib.Solid], List[VLib.Solid]]] TEMPLATE_LOCATION = 'bee2/templates.vmf' + class TEMP_TYPES(Enum): """Value used for import_template()'s force_type parameter. """ @@ -63,6 +61,15 @@ def __str__(self): if self is MAT_TYPES.white: return 'white' +# A dictionary mapping origins to their brushes +solidGroup = NamedTuple('solidGroup', [ + ('face', VLib.Side), + ('solid', VLib.Solid), + ('normal', Vec), + ('color', MAT_TYPES) +]) +SOLIDS = {} # type: Dict[utils.Vec_tuple, solidGroup] + xp = utils.Vec_tuple(1, 0, 0) xn = utils.Vec_tuple(-1, 0, 0) @@ -689,6 +696,7 @@ def import_template( entity will be returned. If there are no detail brushes, None will be returned instead of an invalid entity. """ + import vbsp orig_world, orig_detail = TEMPLATES[temp_name] new_world = [] new_detail = [] @@ -719,6 +727,10 @@ def import_template( else: detail_ent = None + # Don't let these get retextured normally - that should be + # done by retexture_template(), if at all! + vbsp.IGNORED_FACES.update(new_world, new_detail) + return new_world, detail_ent @@ -728,7 +740,7 @@ def retexture_template( origin: Vec, replace_tex: dict=utils.EmptyMapping, force_colour: MAT_TYPES=None, - force_grid:str=None, + force_grid: str=None, ): """Retexture a template at the given location. @@ -739,8 +751,8 @@ def retexture_template( same type. - replace_tex is a replacement table. This overrides everything else. - If force_colour is set, all tile textures will be switched accordingly. - - If force_grid is set, all tile textures will be that size - ('wall', '2x2', '4x4', 'special') + - If force_grid is set, all tile textures will be that size: + ('wall', '2x2', '4x4', 'special') """ import vbsp all_brushes = list(world) @@ -748,6 +760,11 @@ def retexture_template( all_brushes.extend(detail.solids) rand_prefix = 'TEMPLATE_{}_{}_{}:'.format(*origin) + # Even if not axis-aligned, make mostly-flat surfaces + # floor/ceiling (+-40 degrees) + # sin(40) = ~0.707 + floor_tolerance = 0.8 + for brush in all_brushes: for face in brush: folded_mat = face.mat.casefold() @@ -777,33 +794,20 @@ def retexture_template( if force_grid is not None: grid_size = force_grid - if grid_size == 'special': - # Various fallbacks if not defines - face.mat = vbsp.get_tex( - 'special.{!s}_wall'.format(tex_colour) - ) - if face.mat == '': - face.mat = vbsp.get_tex( - 'special.{!s}'.format(tex_colour) - ) - if face.mat == '': - grid_size = 'wall' - # No special texture, use wall texture - else: - continue - if 1 in norm or -1 in norm: # If axis-aligned, make the orientation aligned to world # That way multiple items merge well, and walls are upright face.offset = 0 # Floor / ceiling is always 1 size - 4x4 - if norm == (0, 0, 1): - grid_size = 'ceiling' + if norm.z == (0, 0, 1): + if grid_size != 'special': + grid_size = 'ceiling' face.uaxis = VLib.UVAxis(1, 0, 0) face.vaxis = VLib.UVAxis(0, -1, 0) elif norm == (0, 0, -1): - grid_size = 'floor' + if grid_size != 'special': + grid_size = 'floor' face.uaxis = VLib.UVAxis(1, 0, 0) face.vaxis = VLib.UVAxis(0, -1, 0) # Walls: @@ -814,6 +818,30 @@ def retexture_template( face.uaxis = VLib.UVAxis(1, 0, 0) face.vaxis = VLib.UVAxis(0, 0, -1) + if grid_size == 'special': + # Don't use wall on faces similar to floor/ceiling: + if -floor_tolerance < norm.z < floor_tolerance: + face.mat = vbsp.get_tex( + 'special.{!s}_wall'.format(tex_colour) + ) + else: + face.mat = '' # Ensure next if statement triggers + + # Various fallbacks if not defined + if face.mat == '': + face.mat = vbsp.get_tex( + 'special.{!s}'.format(tex_colour) + ) + if face.mat != '': + continue # Set to a special texture, + # don't use the wall one + else: + utils.con_log(grid_size, norm.z) + if norm.z > floor_tolerance: + grid_size = 'ceiling' + if norm.z < -floor_tolerance: + grid_size = 'floor' + face.mat = vbsp.get_tex( '{!s}.{!s}'.format(tex_colour, grid_size) ) @@ -1137,7 +1165,6 @@ def flag_brush_at_loc(inst, flag): des_type = flag['type', 'any'].casefold() brush = SOLIDS.get(pos.as_tuple(), None) - ':type brush: solidGroup' if brush is None or brush.normal != norm: br_type = 'none' @@ -2819,7 +2846,7 @@ def res_add_brush(inst, res): def res_import_template_setup(res): temp_id = res['id'].casefold() - force = res['force', 'none'].casefold().split() + force = res['force', ''].casefold().split() if 'white' in force: force_colour = MAT_TYPES.white elif 'black' in force: @@ -2827,7 +2854,14 @@ def res_import_template_setup(res): else: force_colour = None - for size in ('2x2', '4x4', 'wall', 'special') : + if 'world' in force: + force_type = TEMP_TYPES.world + elif 'detail' in force: + force_type = TEMP_TYPES.detail + else: + force_type = TEMP_TYPES.default + + for size in ('2x2', '4x4', 'wall', 'special'): if size in force: force_grid = size break @@ -2839,7 +2873,13 @@ def res_import_template_setup(res): for prop in res.find_key('replace', []) } - return temp_id, replace_tex, force_colour, force_grid + return ( + temp_id, + replace_tex, + force_colour, + force_grid, + force_type, + ) @make_result('TemplateBrush') @@ -2852,12 +2892,19 @@ def res_import_template(inst, res): - force: a space-seperated list of overrides. If 'white' or 'black' is present, the colour of tiles will be overriden. If a tile size ('2x2', '4x4', 'wall', 'special') is included, all tiles will - be switched to that size (if not a floor/ceiling) + be switched to that size (if not a floor/ceiling). If 'world' or + 'detail' is present, the brush will be forced to that type. - replace: A block of template material -> replacement textures. This is case insensitive - any texture here will not be altered otherwise. """ - temp_id, replace_tex, force_colour, force_grid = res.value + ( + temp_id, + replace_tex, + force_colour, + force_grid, + force_type, + ) = res.value if temp_id not in TEMPLATES: # The template map is read in after setup is performed, so @@ -2868,7 +2915,12 @@ def res_import_template(inst, res): origin = Vec.from_str(inst['origin']) angles = Vec.from_str(inst['angles', '0 0 0']) - world, detail = import_template(temp_id, origin, angles) + world, detail = import_template( + temp_id, + origin, + angles, + force_type, + ) retexture_template( world, detail, From 0928d683f249552b9d9c74668c07a560ef4df085 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Wed, 11 Nov 2015 17:57:25 +1000 Subject: [PATCH 18/91] Fix incorrect objects count on loading screen --- src/packageLoader.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/packageLoader.py b/src/packageLoader.py index 1cc0995a6..91e37bded 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -191,25 +191,27 @@ def load_packages( obj_override[obj_type] = defaultdict(list) data[obj_type] = [] - objects = 0 images = 0 for pak_id, (zip_file, info, name, dispName) in packages.items(): print( ("Reading objects from '" + pak_id + "'...").ljust(50), end='' ) - obj_count, img_count = parse_package( + img_count = parse_package( zip_file, info, pak_id, dispName, ) - objects += obj_count images += img_count loader.step("PAK") print("Done!") - loader.set_length("OBJ", objects) + loader.set_length("OBJ", sum( + len(obj_type) + for obj_type in + all_obj.values() + )) loader.set_length("IMG_EX", images) # The number of images we need to load is the number of objects, @@ -278,7 +280,6 @@ def load_packages( log_item_fallbacks, log_missing_styles, ) - print(data['zips']) print('Done!') return data @@ -295,7 +296,6 @@ def parse_package(zip_file, info, pak_id, disp_name): '" - ignoring package!' ) return False - objects = 0 # First read through all the components we have, so we can match # overrides to the originals for comp_type in OBJ_TYPES: @@ -317,7 +317,6 @@ def parse_package(zip_file, info, pak_id, disp_name): ) else: raise Exception('ERROR! "' + obj_id + '" defined twice!') - objects += 1 all_obj[comp_type][obj_id] = ObjData( zip_file, obj, @@ -333,7 +332,7 @@ def parse_package(zip_file, info, pak_id, disp_name): extract_packages.res_count += 1 if item.startswith(img_loc): img_count += 1 - return objects, img_count + return img_count def setup_style_tree(item_data, style_data, log_fallbacks, log_missing_styles): From a38a6575aa5b3b84d8448ae164131b005120c553 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Wed, 11 Nov 2015 18:22:19 +1000 Subject: [PATCH 19/91] Add cross-platform global mousewheel support --- src/StyleVarPane.py | 25 ++++---------------- src/UI.py | 32 ++------------------------ src/selectorWin.py | 2 ++ src/utils.py | 56 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 52 deletions(-) diff --git a/src/StyleVarPane.py b/src/StyleVarPane.py index 2abad0445..1db370b16 100644 --- a/src/StyleVarPane.py +++ b/src/StyleVarPane.py @@ -106,10 +106,6 @@ def set_stylevar(var): update_filter() -def scroll(delta): - UI['style_can'].yview_scroll(delta, "units") - - def refresh(selected_style): """Move the stylevars to the correct position. @@ -177,6 +173,9 @@ def make_pane(tool_frame): ) UI['style_scroll'].grid(column=1, row=0, rowspan=2, sticky="NS") UI['style_can']['yscrollcommand'] = UI['style_scroll'].set + + utils.add_mousewheel(UI['style_can'], window) + canvas_frame = ttk.Frame(UI['style_can']) frame_all = ttk.Labelframe(canvas_frame, text="All:") @@ -257,20 +256,4 @@ def make_pane(tool_frame): cursor=utils.CURSORS['stretch_vert'], ).grid(row=1, column=0) - UI['style_can'].bind('', flow_stylevar) - - # Scroll globally even if canvas is not selected. - if utils.WIN: - window.bind( - "", - lambda e: scroll(int(-1*(e.delta/120))), - ) - elif utils.MAC: - window.bind( - "", - lambda e: scroll(1), - ) - window.bind( - "", - lambda e: scroll(-1), - ) \ No newline at end of file + UI['style_can'].bind('', flow_stylevar) \ No newline at end of file diff --git a/src/UI.py b/src/UI.py index e31748294..2fa7146fd 100644 --- a/src/UI.py +++ b/src/UI.py @@ -1659,36 +1659,8 @@ def init_windows(): 'Fill empty spots in the palette with random items.', ) - # make scrollbar work globally - if utils.WIN: - TK_ROOT.bind( - "", - lambda e: pal_canvas.yview_scroll(int(-1*(e.delta/120)), "units"), - ) - elif utils.LINUX: - TK_ROOT.bind( - "", - lambda e: pal_canvas.yview_scroll(1, "units"), - ) - TK_ROOT.bind( - "", - lambda e: pal_canvas.yview_scroll(-1, "units"), - ) - - if utils.WIN: - StyleVarPane.window.bind( - "", - lambda e: UI['style_can'].yview_scroll(int(-1*(e.delta/120)), "units"), - ) - elif utils.LINUX: - StyleVarPane.window.bind( - "", - lambda e: UI['style_can'].yview_scroll(1, "units"), - ) - StyleVarPane.window.bind( - "", - lambda e: UI['style_can'].yview_scroll(-1, "units"), - ) + # Make scrollbar work globally + utils.add_mousewheel(pal_canvas, TK_ROOT) # When clicking on any window hide the context window utils.bind_leftclick(TK_ROOT, contextWin.hide_context) diff --git a/src/selectorWin.py b/src/selectorWin.py index 5ceefe378..10253919c 100644 --- a/src/selectorWin.py +++ b/src/selectorWin.py @@ -268,6 +268,8 @@ def __init__( self.wid_scroll.grid(row=0, column=1, sticky="NS") self.wid_canvas['yscrollcommand'] = self.wid_scroll.set + utils.add_mousewheel(self.wid_canvas, self.win) + if utils.MAC: # Labelframe doesn't look good here on OSX self.sugg_lbl = ttk.Label( diff --git a/src/utils.py b/src/utils.py index 1c73b8698..55ac0941e 100644 --- a/src/utils.py +++ b/src/utils.py @@ -76,6 +76,23 @@ 'destroy_item': 'x_cursor', 'invalid_drag': 'no', } + + def add_mousewheel(target, *frames, orient='y'): + """Add events so scrolling anywhere in a frame will scroll a target. + + frames should be the TK objects to bind to - mainly Frame or + Toplevel objects. + Set orient to 'x' or 'y'. + This is needed since different platforms handle mousewheel events + differently - Windows needs the delta value to be divided by 120. + """ + scroll_func = getattr(target, orient + 'view_scroll') + + def mousewheel_handler(event): + scroll_func(int(event.delta / -120), "units") + for frame in frames: + frame.bind('', mousewheel_handler, add='+') + elif MAC: EVENTS = { 'LEFT': '', @@ -114,6 +131,21 @@ 'destroy_item': 'poof', 'invalid_drag': 'notallowed', } + + def add_mousewheel(target, *frames, orient='y'): + """Add events so scrolling anywhere in a frame will scroll a target. + + frame should be a sequence of any TK objects, like a Toplevel or Frame. + Set orient to 'x' or 'y'. + This is needed since different platforms handle mousewheel events + differently - OS X needs the delta value passed unmodified. + """ + scroll_func = getattr(target, orient + 'view_scroll') + + def mousewheel_handler(event): + scroll_func(event.delta, "units") + for frame in frames: + frame.bind('', mousewheel_handler, add='+') elif LINUX: EVENTS = { 'LEFT': '', @@ -151,12 +183,34 @@ 'invalid_drag': 'no', } + def add_mousewheel(target, *frames, orient='y'): + """Add events so scrolling anywhere in a frame will scroll a target. + + frame should be a sequence of any TK objects, like a Toplevel or Frame. + Set orient to 'x' or 'y'. + This is needed since different platforms handle mousewheel events + differently - Linux uses Button-4 and Button-5 events instead of + a MouseWheel event. + """ + scroll_func = getattr(target, orient + 'view_scroll') + + def scroll_up(_): + scroll_func(-1, "units") + + def scroll_down(_): + scroll_func(1, "units") + + for frame in frames: + frame.bind('', scroll_up, add='+') + frame.bind('', scroll_down, add='+') + if MAC: # On OSX, make left-clicks switch to a rightclick when control is held. def bind_leftclick(wid, func): """On OSX, left-clicks are converted to right-clicks - when control is held.""" + when control is held. + """ def event_handler(e): # e.state is a set of binary flags # Don't run the event if control is held! From 5c9d3cd4fc3c255f3c083542507eb5158d94e686 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Fri, 13 Nov 2015 16:04:22 +1000 Subject: [PATCH 20/91] Rename tk_root to tk_utils --- src/BEE2.pyw | 2 +- src/CompilerPane.py | 2 +- src/StyleVarPane.py | 2 +- src/contextWin.py | 2 +- src/extract_packages.py | 2 +- src/gameMan.py | 2 +- src/itemPropWin.py | 2 +- src/loadScreen.py | 2 +- src/optionWindow.py | 2 +- src/selectorWin.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/BEE2.pyw b/src/BEE2.pyw index 8106d9bce..cc9a97d91 100644 --- a/src/BEE2.pyw +++ b/src/BEE2.pyw @@ -25,7 +25,7 @@ if __name__ == '__main__': # BEE2_config creates this config file to allow easy cross-module access from BEE2_config import GEN_OPTS - from tk_root import TK_ROOT + from tk_tools import TK_ROOT import UI import loadScreen import paletteLoader diff --git a/src/CompilerPane.py b/src/CompilerPane.py index e171ab3c1..59b459c88 100644 --- a/src/CompilerPane.py +++ b/src/CompilerPane.py @@ -1,5 +1,5 @@ from tkinter import * -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from tkinter import ttk from tkinter import filedialog diff --git a/src/StyleVarPane.py b/src/StyleVarPane.py index 1db370b16..7e45f0fac 100644 --- a/src/StyleVarPane.py +++ b/src/StyleVarPane.py @@ -1,5 +1,5 @@ from tkinter import * -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from tkinter import ttk from collections import namedtuple diff --git a/src/contextWin.py b/src/contextWin.py index 5465d5f63..4522ea373 100644 --- a/src/contextWin.py +++ b/src/contextWin.py @@ -8,7 +8,7 @@ clicked widget from the event """ from tkinter import * -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from tkinter import ttk from tkinter import messagebox diff --git a/src/extract_packages.py b/src/extract_packages.py index 4715786b3..aca3dbc68 100644 --- a/src/extract_packages.py +++ b/src/extract_packages.py @@ -11,7 +11,7 @@ from zipfile import ZipFile from FakeZip import zip_names, FakeZip -from tk_root import TK_ROOT +from tk_tools import TK_ROOT UPDATE_INTERVAL = 500 # Number of miliseconds between each progress check diff --git a/src/gameMan.py b/src/gameMan.py index 4bef51b9c..52d45c637 100644 --- a/src/gameMan.py +++ b/src/gameMan.py @@ -12,7 +12,7 @@ from tkinter import * # ui library from tkinter import messagebox # simple, standard modal dialogs from tkinter import filedialog # open/save as dialog creator -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from query_dialogs import ask_string from BEE2_config import ConfigFile diff --git a/src/itemPropWin.py b/src/itemPropWin.py index a9cc6fc21..6cb56bc85 100644 --- a/src/itemPropWin.py +++ b/src/itemPropWin.py @@ -1,6 +1,6 @@ #coding: utf-8 from tkinter import * # ui library -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from tkinter import ttk # themed ui components that match the OS from functools import partial as func_partial import math diff --git a/src/loadScreen.py b/src/loadScreen.py index 904b2bf7e..8389adfe8 100644 --- a/src/loadScreen.py +++ b/src/loadScreen.py @@ -1,6 +1,6 @@ """Displays a loading menu while packages, palettes, etc are being loaded.""" from tkinter import * # ui library -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from tkinter import ttk # themed ui components that match the OS import utils diff --git a/src/optionWindow.py b/src/optionWindow.py index c50a3ee9b..d1011776b 100644 --- a/src/optionWindow.py +++ b/src/optionWindow.py @@ -1,7 +1,7 @@ # coding=utf-8 from tkinter import * from tkinter import ttk -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from BEE2_config import GEN_OPTS from tooltip import add_tooltip diff --git a/src/selectorWin.py b/src/selectorWin.py index 10253919c..e53433c91 100644 --- a/src/selectorWin.py +++ b/src/selectorWin.py @@ -8,7 +8,7 @@ from tkinter import font from tkinter import ttk # themed ui components that match the OS from collections import namedtuple -from tk_root import TK_ROOT +from tk_tools import TK_ROOT import functools import math From f2c8e53156ca1109e1fc4c59e7cb126fcaaec7b1 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Fri, 13 Nov 2015 16:05:16 +1000 Subject: [PATCH 21/91] Rename tk_root to tk_utils --- src/UI.py | 2 +- src/contextWin.py | 2 +- src/tk_root.py | 16 ---------------- src/tk_tools.py | 16 ++++++++++++++++ src/tooltip.py | 2 +- src/voiceEditor.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) delete mode 100644 src/tk_root.py create mode 100644 src/tk_tools.py diff --git a/src/UI.py b/src/UI.py index 2fa7146fd..480d79680 100644 --- a/src/UI.py +++ b/src/UI.py @@ -7,7 +7,7 @@ import operator import random -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from query_dialogs import ask_string from itemPropWin import PROP_TYPES from BEE2_config import ConfigFile, GEN_OPTS diff --git a/src/contextWin.py b/src/contextWin.py index 4522ea373..e95901234 100644 --- a/src/contextWin.py +++ b/src/contextWin.py @@ -417,7 +417,7 @@ def init_widgets(): wid['desc'] = tkRichText(desc_frame, width=40, height=8, font=None) wid['desc'].grid(row=0, column=0, sticky="EW") - desc_scroll = ttk.Scrollbar( + desc_scroll = tk_tools.HidingScroll( desc_frame, orient=VERTICAL, command=wid['desc'].yview, diff --git a/src/tk_root.py b/src/tk_root.py deleted file mode 100644 index e37e708bc..000000000 --- a/src/tk_root.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Special module used to ensure other modules use the same TK instance. - -When Tk() is called, a toplevel window is generated for the application. -Putting this in a module ensures only one is ever created. - -""" -from tkinter import Tk -import utils - - -TK_ROOT = Tk() -if utils.WIN: - # Ensure everything has our icon (including dialogs) - TK_ROOT.wm_iconbitmap(default='../BEE2.ico') -TK_ROOT.withdraw() # Hide the window until everything is loaded. diff --git a/src/tk_tools.py b/src/tk_tools.py new file mode 100644 index 000000000..fee8d52a0 --- /dev/null +++ b/src/tk_tools.py @@ -0,0 +1,16 @@ +""" +General code used for tkinter portions. + +""" +from tkinter import ttk +import tkinter as tk +import utils + +# Put this in a module so it's a singleton, and we can always import the same +# object. +TK_ROOT = tk.Tk() + +if utils.WIN: + # Ensure everything has our icon (including dialogs) + TK_ROOT.wm_iconbitmap(default='../BEE2.ico') +TK_ROOT.withdraw() # Hide the window until everything is loaded. diff --git a/src/tooltip.py b/src/tooltip.py index b99650bb9..faaa46169 100644 --- a/src/tooltip.py +++ b/src/tooltip.py @@ -5,7 +5,7 @@ or call show and hide to directly move the window. """ import tkinter as tk -from tk_root import TK_ROOT +from tk_tools import TK_ROOT import utils diff --git a/src/voiceEditor.py b/src/voiceEditor.py index 8548cfa9e..a56b142e5 100644 --- a/src/voiceEditor.py +++ b/src/voiceEditor.py @@ -1,5 +1,5 @@ from tkinter import * -from tk_root import TK_ROOT +from tk_tools import TK_ROOT from tkinter import ttk from tkinter import font From a7240bfbab8891843851141e197186ac7cfbd591 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Fri, 13 Nov 2015 16:29:26 +1000 Subject: [PATCH 22/91] Hide scrollbars when not needed If windows are big enough to show the entire content, scrollbars will hide themselves --- src/SubPane.py | 4 ++-- src/UI.py | 14 +++++++++++--- src/contextWin.py | 1 + src/selectorWin.py | 7 ++++--- src/tagsPane.py | 3 ++- src/tk_tools.py | 14 ++++++++++++++ src/voiceEditor.py | 5 +++-- 7 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/SubPane.py b/src/SubPane.py index 46ac33a87..06ee0d4d7 100644 --- a/src/SubPane.py +++ b/src/SubPane.py @@ -54,8 +54,8 @@ def __init__( self.can_resize_x = resize_x self.can_resize_y = resize_y self.config_file = options - super().__init__(parent) - self.withdraw() # Hide by default + super().__init__(parent, name='pane_' + name) + self.withdraw() # Hide by default self.tool_button = make_tool_button( frame=tool_frame, diff --git a/src/UI.py b/src/UI.py index 480d79680..cfda14d7b 100644 --- a/src/UI.py +++ b/src/UI.py @@ -2,7 +2,6 @@ from tkinter import * # ui library from tkinter import ttk # themed ui components that match the OS from tkinter import messagebox # simple, standard modal dialogs -from functools import partial as func_partial import itertools import operator import random @@ -16,6 +15,7 @@ import paletteLoader import img import utils +import tk_tools import SubPane from selectorWin import selWin, Item as selWinItem, AttrDef as SelAttr import extract_packages @@ -1097,7 +1097,11 @@ def set_pal_listbox(_=None): # selected. UI['palette'].selection_set(0) - pal_scroll = ttk.Scrollbar(f, orient=VERTICAL, command=UI['palette'].yview) + pal_scroll = tk_tools.HidingScroll( + f, + orient=VERTICAL, + command=UI['palette'].yview, + ) pal_scroll.grid(row=1, column=1, sticky="NS") UI['palette']['yscrollcommand'] = pal_scroll.set @@ -1320,7 +1324,11 @@ def init_picker(f): cframe.rowconfigure(0, weight=1) cframe.columnconfigure(0, weight=1) - scroll = ttk.Scrollbar(cframe, orient=VERTICAL, command=pal_canvas.yview) + scroll = tk_tools.HidingScroll( + cframe, + orient=VERTICAL, + command=pal_canvas.yview, + ) scroll.grid(column=1, row=0, sticky="NS") pal_canvas['yscrollcommand'] = scroll.set diff --git a/src/contextWin.py b/src/contextWin.py index e95901234..267c150f0 100644 --- a/src/contextWin.py +++ b/src/contextWin.py @@ -20,6 +20,7 @@ import sound as snd import itemPropWin import tooltip +import tk_tools import utils OPEN_IN_TAB = 2 diff --git a/src/selectorWin.py b/src/selectorWin.py index e53433c91..15af91259 100644 --- a/src/selectorWin.py +++ b/src/selectorWin.py @@ -7,14 +7,15 @@ from tkinter import * # ui library from tkinter import font from tkinter import ttk # themed ui components that match the OS -from collections import namedtuple from tk_tools import TK_ROOT +from collections import namedtuple import functools import math import img # png library for TKinter from richTextBox import tkRichText import utils +import tk_tools ICON_SIZE = 96 # Size of the selector win icons ITEM_WIDTH = ICON_SIZE + (32 if utils.MAC else 16) @@ -260,7 +261,7 @@ def __init__( self.pal_frame = ttk.Frame(self.wid_canvas) self.wid_canvas.create_window(1, 1, window=self.pal_frame, anchor="nw") - self.wid_scroll = ttk.Scrollbar( + self.wid_scroll = tk_tools.HidingScroll( shim, orient=VERTICAL, command=self.wid_canvas.yview, @@ -334,7 +335,7 @@ def __init__( sticky='NSEW', ) - self.prop_scroll = ttk.Scrollbar( + self.prop_scroll = tk_tools.HidingScroll( self.prop_desc_frm, orient=VERTICAL, command=self.prop_desc.yview, diff --git a/src/tagsPane.py b/src/tagsPane.py index 1922066aa..661fda93e 100644 --- a/src/tagsPane.py +++ b/src/tagsPane.py @@ -9,6 +9,7 @@ import StyleVarPane import UI import utils +import tk_tools is_expanded = False wid = {} @@ -182,7 +183,7 @@ def init(frm): underline=1, ) - wid['tag_scroll'] = tag_scroll = ttk.Scrollbar( + wid['tag_scroll'] = tag_scroll = tk_tools.HidingScroll( exp, orient=tk.VERTICAL, command=tag_list.yview, diff --git a/src/tk_tools.py b/src/tk_tools.py index fee8d52a0..988c045db 100644 --- a/src/tk_tools.py +++ b/src/tk_tools.py @@ -14,3 +14,17 @@ # Ensure everything has our icon (including dialogs) TK_ROOT.wm_iconbitmap(default='../BEE2.ico') TK_ROOT.withdraw() # Hide the window until everything is loaded. + + +class HidingScroll(ttk.Scrollbar): + """A scrollbar variant which auto-hides when not needed. + + """ + def set(self, low, high): + """Set the size needed for the scrollbar, and hide/show if needed.""" + if float(low) <= 0.0 and float(high) >= 1.0: + # Remove this, but remember gridding options + self.grid_remove() + else: + self.grid() + super(HidingScroll, self).set(low, high) diff --git a/src/voiceEditor.py b/src/voiceEditor.py index a56b142e5..dd6014c54 100644 --- a/src/voiceEditor.py +++ b/src/voiceEditor.py @@ -10,6 +10,7 @@ from BEE2_config import ConfigFile import img import utils +import tk_tools voice_item = None @@ -76,7 +77,7 @@ def init_widgets(): state='disabled', font=('Helvectia', 10), ) - UI['trans_scroll'] = ttk.Scrollbar( + UI['trans_scroll'] = tk_tools.HidingScroll( trans_inner_frame, orient=VERTICAL, command=UI['trans'].yview, @@ -251,7 +252,7 @@ def make_tab(group, config, is_mid=False): outer_frame, highlightthickness=0, ) - scroll = ttk.Scrollbar( + scroll = tk_tools.HidingScroll( outer_frame, orient=VERTICAL, command=canv.yview, From af5281023f01eeac0c7f5a538a9686a8b8c68de9 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Fri, 13 Nov 2015 18:24:54 +1000 Subject: [PATCH 23/91] Add framework for CheckDetails --- src/CheckDetails.py | 96 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/CheckDetails.py diff --git a/src/CheckDetails.py b/src/CheckDetails.py new file mode 100644 index 000000000..29dc5152f --- /dev/null +++ b/src/CheckDetails.py @@ -0,0 +1,96 @@ +""" +A widget which displays items in a row with various attributes. + +Headings can +be clicked to sort, the item can be enabled/disabled, and info can be shown +via tooltips +""" +from tkinter import ttk +import tkinter as tk +import tk_tools + + +class Item: + """Represents one item in a CheckDetails list. + + """ + def __init__(self, *values): + self.values = values + self.state = tk.BooleanVar(value=False) + + +class _ItemRow: + """Holds the widgets displayed in each row. + """ + def __init__(self, master: 'CheckDetails', item: 'Item'): + self.widget = master + self.item = item + self.check = ttk.Checkbutton( + master.wid_canvas, + ) + + +class CheckDetails(ttk.Frame): + def __init__(self, parent, items=(), headers=()): + super(CheckDetails, self).__init__(parent) + + self.parent = parent + self.headers = list(headers) + self.items = [] + # The widgets used for rows + self._wid_rows = {} + + self.wid_canvas = tk.Canvas( + self, + ) + self.wid_canvas.grid(row=0, column=0, sticky='NSEW') + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + self.horiz_scroll = tk_tools.HidingScroll( + self, + orient=tk.HORIZONTAL, + command=self.wid_canvas.xview, + ) + self.vert_scroll = tk_tools.HidingScroll( + self, + orient=tk.VERTICAL, + command=self.wid_canvas.yview, + ) + self.wid_canvas['xscrollcommand'] = self.horiz_scroll.set + self.wid_canvas['yscrollcommand'] = self.vert_scroll.set + + self.horiz_scroll.grid(row=1, column=0, sticky='EW') + self.vert_scroll.grid(row=0, column=1, sticky='NS') + + for item in items: + self.add_item(item) + + def add_item(self, item): + self.items.append(item) + self._wid_rows[item] = _ItemRow(self, item) + + def rem_item(self, item): + self.items.remove(item) + del self._wid_rows[item] + + def refresh(self): + """Reposition the widgets.""" + pass + + +if __name__ == '__main__': + root = tk.Tk() + test_inst = CheckDetails( + parent=root, + headers=['Name', 'Author', 'Description'], + items=[ + Item('Item1', 'Auth1'), + Item('Item2', 'Auth1'), + Item('Item3', 'Auth2'), + ] + ) + test_inst.grid() + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + root.mainloop() \ No newline at end of file From d35ccbc594a3d535dce3c7e70d7c858509150bc0 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 10:18:00 +1000 Subject: [PATCH 24/91] Add headers --- src/CheckDetails.py | 154 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 132 insertions(+), 22 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 29dc5152f..1a7c94304 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -7,8 +7,13 @@ """ from tkinter import ttk import tkinter as tk + +import utils import tk_tools +UP_ARROW = '\u25B3' +DN_ARROW = '\u25BD' + class Item: """Represents one item in a CheckDetails list. @@ -16,19 +21,35 @@ class Item: """ def __init__(self, *values): self.values = values - self.state = tk.BooleanVar(value=False) - - -class _ItemRow: - """Holds the widgets displayed in each row. - """ - def __init__(self, master: 'CheckDetails', item: 'Item'): - self.widget = master - self.item = item + self.state_var = None + self.widget = None + self.check = None + self.master = None + + def make_widgets(self, master): + if self.master is not None: + # If we let items move between lists, the old widgets will become + # orphaned! + raise ValueError( + "Can't move Item objects between lists!" + ) + + self.master = master + self.state_var = tk.BooleanVar(value=False) self.check = ttk.Checkbutton( master.wid_canvas, + variable=self.state_var, ) + @property + def state(self) -> bool: + return self.state_var.get() + + @state.setter + def state(self, value: bool): + self.state_var.set(value) + self.master.update_allcheck() + class CheckDetails(ttk.Frame): def __init__(self, parent, items=(), headers=()): @@ -37,15 +58,37 @@ def __init__(self, parent, items=(), headers=()): self.parent = parent self.headers = list(headers) self.items = [] - # The widgets used for rows - self._wid_rows = {} + + self.head_check_var = tk.IntVar(value=0) + self.wid_head_check = ttk.Checkbutton( + self, + takefocus=False, + variable=self.head_check_var, + onvalue=1, + offvalue=0, + ) + self.wid_head_check.grid(row=0, column=0) + + self.wid_header = tk.PanedWindow( + self, + orient=tk.HORIZONTAL, + sashrelief=tk.RAISED, + sashpad=2, + showhandle=False, + ) + self.wid_header.grid(row=0, column=1, sticky='EW') + self.wid_head_label = [0] * len(self.headers) + self.wid_head_sort = [0] * len(self.headers) + self.make_headers() self.wid_canvas = tk.Canvas( self, + relief=tk.SUNKEN, + background='white', ) - self.wid_canvas.grid(row=0, column=0, sticky='NSEW') - self.columnconfigure(0, weight=1) - self.rowconfigure(0, weight=1) + self.wid_canvas.grid(row=1, column=0, columnspan=2, sticky='NSEW') + self.columnconfigure(1, weight=1) + self.rowconfigure(1, weight=1) self.horiz_scroll = tk_tools.HidingScroll( self, @@ -60,23 +103,90 @@ def __init__(self, parent, items=(), headers=()): self.wid_canvas['xscrollcommand'] = self.horiz_scroll.set self.wid_canvas['yscrollcommand'] = self.vert_scroll.set - self.horiz_scroll.grid(row=1, column=0, sticky='EW') - self.vert_scroll.grid(row=0, column=1, sticky='NS') + self.horiz_scroll.grid(row=2, column=0, columnspan=2, sticky='EW') + self.vert_scroll.grid(row=1, column=1, sticky='NS') + + self.wid_frame = ttk.Frame( + self.wid_canvas, + ) + self.wid_canvas.create_window(0, 0, window=self.wid_frame, anchor='nw') + + self.bind('', self.refresh) + utils.add_mousewheel(self.wid_canvas, self) for item in items: self.add_item(item) + def make_headers(self): + """Generate the heading widgets.""" + + for i, head_text in enumerate(self.headers): + header = ttk.Frame( + self.wid_header, + relief=tk.RAISED, + ) + + self.wid_head_label[i] = label = ttk.Label( + header, + text=head_text, + ) + self.wid_head_sort[i] = sorter = ttk.Label( + header, + text='', + ) + label.grid(row=0, column=0, sticky='EW') + sorter.grid(row=0, column=1, sticky='E') + header.columnconfigure(0, weight=1) + self.wid_header.add(header) + + def header_enter(e, wid=label): + wid['background'] = 'lightblue' + + def header_leave(_, wid=label): + wid['background'] = '' + + header.bind('', header_enter) + header.bind('', header_leave) + + # Headers can't become smaller than their initial size - + # The amount of space to show all the text + arrow + header.update_idletasks() + self.wid_header.paneconfig( + header, + minsize=header.winfo_reqwidth(), + ) + + sorter['text'] = '' + def add_item(self, item): self.items.append(item) - self._wid_rows[item] = _ItemRow(self, item) + item.make_widgets(self) def rem_item(self, item): self.items.remove(item) - del self._wid_rows[item] - def refresh(self): - """Reposition the widgets.""" - pass + def update_allcheck(self): + """Update the 'all' checkbox to match the state of sub-boxes.""" + self.head_check_var.set( + any(item.state for item in self.items) + ) + + def refresh(self, _=None): + """Reposition the widgets. + + Must be called when self.items or _wid_rows is changed, + or when window is resized. + """ + + # Set the size of the canvas + self.wid_frame.update_idletasks() + + self.wid_canvas['scrollregion'] = ( + 0, + 0, + self.wid_frame.winfo_reqwidth(), + self.wid_frame.winfo_reqheight(), + ) if __name__ == '__main__': @@ -90,7 +200,7 @@ def refresh(self): Item('Item3', 'Auth2'), ] ) - test_inst.grid() + test_inst.grid(sticky='NSEW') root.columnconfigure(0, weight=1) root.rowconfigure(0, weight=1) root.mainloop() \ No newline at end of file From d264cfa7fa95e8e9ea20cefd7ab36ce28c1aef65 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 12:36:48 +1000 Subject: [PATCH 25/91] Add item logic --- src/CheckDetails.py | 202 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 167 insertions(+), 35 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 1a7c94304..845b2efa3 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -8,12 +8,24 @@ from tkinter import ttk import tkinter as tk +import functools + +from tooltip import add_tooltip import utils import tk_tools + UP_ARROW = '\u25B3' DN_ARROW = '\u25BD' +ROW_HEIGHT = 24 +ROW_PADDING = 2 + +style = ttk.Style() +style.configure( + 'CheckDetails.TCheckbutton', + background='white', +) class Item: """Represents one item in a CheckDetails list. @@ -21,12 +33,12 @@ class Item: """ def __init__(self, *values): self.values = values - self.state_var = None - self.widget = None - self.check = None - self.master = None + self.state_var = tk.IntVar(value=0) + self.master = None # type: CheckDetails + self.check = None # type: ttk.Checkbutton + self.val_widgets = [] - def make_widgets(self, master): + def make_widgets(self, master: 'CheckDetails'): if self.master is not None: # If we let items move between lists, the old widgets will become # orphaned! @@ -35,11 +47,45 @@ def make_widgets(self, master): ) self.master = master - self.state_var = tk.BooleanVar(value=False) self.check = ttk.Checkbutton( - master.wid_canvas, + master.wid_frame, variable=self.state_var, + onvalue=1, + offvalue=0, + takefocus=False, + width=0, + style='CheckDetails.TCheckbutton', + command=self.master.update_allcheck, + ) + + self.val_widgets = [ + tk.Label( + master.wid_frame, + text=value, + justify=tk.LEFT, + anchor=tk.W, + background='white', + ) + for value in + self.values + ] + + def place(self, check_width, head_pos, y): + """Position the widgets on the frame.""" + self.check.place( + x=0, + y=y, + width=check_width, + height=ROW_HEIGHT, ) + for widget, (x, width) in zip(self.val_widgets, head_pos): + widget.place( + x=x+check_width, + y=y, + width=width, + height=ROW_HEIGHT, + ) + x += width @property def state(self) -> bool: @@ -58,17 +104,35 @@ def __init__(self, parent, items=(), headers=()): self.parent = parent self.headers = list(headers) self.items = [] + self.sort_ind = None + self.rev_sort = False # Should we sort in reverse? - self.head_check_var = tk.IntVar(value=0) + self.head_check_var = tk.IntVar(value=False) self.wid_head_check = ttk.Checkbutton( self, - takefocus=False, variable=self.head_check_var, - onvalue=1, - offvalue=0, + command=self.toggle_allcheck, + takefocus=False, + width=0, ) self.wid_head_check.grid(row=0, column=0) + add_tooltip( + self.wid_head_check, + "Toggle all checkboxes." + ) + + def checkbox_enter(e): + """When hovering over the 'all' checkbox, highlight the others.""" + for item in self.items: + item.check.state(['active']) + self.wid_head_check.bind('', checkbox_enter) + + def checkbox_leave(e): + for item in self.items: + item.check.state(['!active']) + self.wid_head_check.bind('', checkbox_leave) + self.wid_header = tk.PanedWindow( self, orient=tk.HORIZONTAL, @@ -77,14 +141,13 @@ def __init__(self, parent, items=(), headers=()): showhandle=False, ) self.wid_header.grid(row=0, column=1, sticky='EW') + self.wid_head_frames = [0] * len(self.headers) self.wid_head_label = [0] * len(self.headers) self.wid_head_sort = [0] * len(self.headers) self.make_headers() self.wid_canvas = tk.Canvas( self, - relief=tk.SUNKEN, - background='white', ) self.wid_canvas.grid(row=1, column=0, columnspan=2, sticky='NSEW') self.columnconfigure(1, weight=1) @@ -103,16 +166,24 @@ def __init__(self, parent, items=(), headers=()): self.wid_canvas['xscrollcommand'] = self.horiz_scroll.set self.wid_canvas['yscrollcommand'] = self.vert_scroll.set - self.horiz_scroll.grid(row=2, column=0, columnspan=2, sticky='EW') - self.vert_scroll.grid(row=1, column=1, sticky='NS') + self.horiz_scroll.grid(row=2, column=0, columnspan=2, sticky='EWS') + self.vert_scroll.grid(row=1, column=2, sticky='NSE') + if utils.USE_SIZEGRIP: + ttk.Sizegrip(self).grid(row=2, column=2) - self.wid_frame = ttk.Frame( + self.wid_frame = tk.Frame( self.wid_canvas, + relief=tk.SUNKEN, + background='white', ) self.wid_canvas.create_window(0, 0, window=self.wid_frame, anchor='nw') self.bind('', self.refresh) - utils.add_mousewheel(self.wid_canvas, self) + self.bind('', self.refresh) # When added to a window, refresh + + self.wid_header.bind('', self.refresh) + self.wid_header.bind('', self.refresh) + self.wid_header.bind('', self.refresh) for item in items: self.add_item(item) @@ -121,7 +192,7 @@ def make_headers(self): """Generate the heading widgets.""" for i, head_text in enumerate(self.headers): - header = ttk.Frame( + self.wid_head_frames[i] = header = ttk.Frame( self.wid_header, relief=tk.RAISED, ) @@ -139,14 +210,17 @@ def make_headers(self): header.columnconfigure(0, weight=1) self.wid_header.add(header) - def header_enter(e, wid=label): - wid['background'] = 'lightblue' + def header_enter(_, label=label, sorter=sorter): + label['background'] = 'lightblue' + sorter['background'] = 'lightblue' - def header_leave(_, wid=label): - wid['background'] = '' + def header_leave(_, label=label, sorter=sorter): + label['background'] = '' + sorter['background'] = '' header.bind('', header_enter) header.bind('', header_leave) + utils.bind_leftclick(label, functools.partial(self.sort, i)) # Headers can't become smaller than their initial size - # The amount of space to show all the text + arrow @@ -167,40 +241,98 @@ def rem_item(self, item): def update_allcheck(self): """Update the 'all' checkbox to match the state of sub-boxes.""" - self.head_check_var.set( - any(item.state for item in self.items) - ) + num_checked = sum(item.state for item in self.items) + if num_checked == 0: + self.head_check_var.set(False) + elif num_checked == len(self.items): + self.wid_head_check.state(['!alternate']) + self.head_check_var.set(True) + self.wid_head_check.state(['!alternate']) + else: + # The 'half' state is just visual. + # Set to true so everything is blanked when next clicking + self.head_check_var.set(True) + self.wid_head_check.state(['alternate']) + + def toggle_allcheck(self): + value = self.head_check_var.get() + for item in self.items: + # Bypass the update function + item.state_var.set(value) def refresh(self, _=None): """Reposition the widgets. - Must be called when self.items or _wid_rows is changed, + Must be called when self.items is changed, or when window is resized. """ + self.wid_header.update_idletasks() + header_sizes = [ + (head.winfo_x(), head.winfo_width()) + for head in + self.wid_head_frames + ] + + self.wid_head_check.update_idletasks() + check_width = self.wid_head_check.winfo_width() + pos = ROW_PADDING + for item in self.items: + item.place(check_width, header_sizes, pos) + pos += ROW_HEIGHT + ROW_PADDING + + self.wid_frame['width'] = width = max( + self.wid_canvas.winfo_width(), + sum(header_sizes[-1]) + check_width, + ) + self.wid_frame['height'] = height = max( + self.wid_canvas.winfo_height(), + pos, + ) # Set the size of the canvas self.wid_frame.update_idletasks() - self.wid_canvas['scrollregion'] = ( - 0, - 0, - self.wid_frame.winfo_reqwidth(), - self.wid_frame.winfo_reqheight(), + self.wid_canvas['scrollregion'] = (0, 0, width, height) + + def sort(self, index, _=None): + """Click event for headers.""" + if self.sort_ind is not None: + self.wid_head_sort[self.sort_ind]['text'] = '' + if self.sort_ind == index: + self.rev_sort = not self.rev_sort + else: + self.rev_sort = False + + self.wid_head_sort[index]['text'] = ( + UP_ARROW if self.rev_sort else DN_ARROW + ) + self.sort_ind = index + + self.items.sort( + key=lambda item: item.values[index], + reverse=self.rev_sort, ) + self.refresh() if __name__ == '__main__': - root = tk.Tk() + root = tk_tools.TK_ROOT test_inst = CheckDetails( parent=root, headers=['Name', 'Author', 'Description'], items=[ - Item('Item1', 'Auth1'), - Item('Item2', 'Auth1'), - Item('Item3', 'Auth2'), + Item('Item1', 'Auth1', 'Blah blah blah'), + Item('Item5', 'Auth3', 'Lorem Ipsum'), + Item('Item3', 'Auth2', '.........'), + Item('Item4', 'Auth2', '.........'), + Item('Item6', 'Auth4', '.....'), + Item('Item2', 'Auth1', '...'), ] ) test_inst.grid(sticky='NSEW') + utils.add_mousewheel(test_inst.wid_canvas, root) + root.columnconfigure(0, weight=1) root.rowconfigure(0, weight=1) + root.deiconify() root.mainloop() \ No newline at end of file From 66d24bf9b9c7cecac693c8a91d6efb1ca43346a6 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 13:22:46 +1000 Subject: [PATCH 26/91] Hide/show scrollbars correctly --- src/CheckDetails.py | 62 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 845b2efa3..5fa841e74 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -18,7 +18,7 @@ UP_ARROW = '\u25B3' DN_ARROW = '\u25BD' -ROW_HEIGHT = 24 +ROW_HEIGHT = 16 ROW_PADDING = 2 style = ttk.Style() @@ -98,7 +98,14 @@ def state(self, value: bool): class CheckDetails(ttk.Frame): - def __init__(self, parent, items=(), headers=()): + def __init__(self, parent, items=(), headers=(), add_sizegrip=False): + """Initialise a CheckDetails pane. + + parent is the parent widget. + items is a list of Items objects. + headers is a list of the header strings. + If add_sizegrip is True, add a sizegrip object between the scrollbars. + """ super(CheckDetails, self).__init__(parent) self.parent = parent @@ -153,12 +160,12 @@ def checkbox_leave(e): self.columnconfigure(1, weight=1) self.rowconfigure(1, weight=1) - self.horiz_scroll = tk_tools.HidingScroll( + self.horiz_scroll = ttk.Scrollbar( self, orient=tk.HORIZONTAL, command=self.wid_canvas.xview, ) - self.vert_scroll = tk_tools.HidingScroll( + self.vert_scroll = ttk.Scrollbar( self, orient=tk.VERTICAL, command=self.wid_canvas.yview, @@ -168,13 +175,16 @@ def checkbox_leave(e): self.horiz_scroll.grid(row=2, column=0, columnspan=2, sticky='EWS') self.vert_scroll.grid(row=1, column=2, sticky='NSE') - if utils.USE_SIZEGRIP: - ttk.Sizegrip(self).grid(row=2, column=2) + if add_sizegrip and utils.USE_SIZEGRIP: + self.sizegrip = ttk.Sizegrip(self) + self.sizegrip.grid(row=2, column=2) + else: + self.sizegrip = None self.wid_frame = tk.Frame( self.wid_canvas, - relief=tk.SUNKEN, background='white', + border=0 ) self.wid_canvas.create_window(0, 0, window=self.wid_frame, anchor='nw') @@ -289,6 +299,44 @@ def refresh(self, _=None): pos, ) + has_scroll_horiz = width > self.wid_canvas.winfo_width() + has_scroll_vert = height > self.wid_canvas.winfo_height() + + # Re-grid the canvas, sizegrip and scrollbar to fill in gaps + if self.sizegrip is not None: + if has_scroll_horiz or has_scroll_vert: + self.sizegrip.grid() + else: + self.sizegrip.grid_remove() + + # If only one, extend the canvas to fill the empty space + if has_scroll_horiz and not has_scroll_vert: + self.wid_canvas.grid( + row=1, column=0, sticky='NSEW', + columnspan=3, + ) + elif not has_scroll_horiz and has_scroll_vert: + self.wid_canvas.grid( + row=1, column=0, sticky='NSEW', + columnspan=2, rowspan=2, + ) + else: + # Both or neither, just fit in the original space + self.wid_canvas.grid( + row=1, column=0, sticky='NSEW', + columnspan=2, + ) + + if has_scroll_horiz: + self.horiz_scroll.grid() + else: + self.horiz_scroll.grid_remove() + + if has_scroll_vert: + self.vert_scroll.grid() + else: + self.vert_scroll.grid_remove() + # Set the size of the canvas self.wid_frame.update_idletasks() From b5f6e9e0da92a54ab81a593cddb1f08d5a6f7da0 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 13:30:45 +1000 Subject: [PATCH 27/91] Add copy function --- src/CheckDetails.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 5fa841e74..e04b7d8a4 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -27,6 +27,7 @@ background='white', ) + class Item: """Represents one item in a CheckDetails list. @@ -38,6 +39,9 @@ def __init__(self, *values): self.check = None # type: ttk.Checkbutton self.val_widgets = [] + def copy(self): + return Item(self.values) + def make_widgets(self, master: 'CheckDetails'): if self.master is not None: # If we let items move between lists, the old widgets will become From d4c465ec469ca7891670ad40166c0c4b21db9381 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 14:03:10 +1000 Subject: [PATCH 28/91] Start working on backup module --- src/Backup.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/Backup.py diff --git a/src/Backup.py b/src/Backup.py new file mode 100644 index 000000000..2fd866da9 --- /dev/null +++ b/src/Backup.py @@ -0,0 +1,69 @@ +"""Backup and restore P2C maps. + +""" +import tkinter as tk +from tkinter import ttk +from tk_tools import TK_ROOT + +import time +from zipfile import ZipFile + +from property_parser import Property +from CheckDetails import CheckDetails, Item as CheckItem +import utils +import tk_tools + +window = None # type: tk.Toplevel + +UI = {} + + +class P2C: + """A PeTI map.""" + def __init__(self, path, props): + props = Property.parse(props) + + self.path = path + self.title = props['title', ''] + self.desc = props['description', '...'] + self.is_coop = utils.conv_bool(props['coop', '0']) + self.create_time = props['Timestamp_Created', ''] + + +def read(hex_time): + """Convert the time format in P2C files into a readable string.""" + try: + val = int(hex_time, 16) + except ValueError: + return '??' + date = time.localtime(val) + return time.strftime( + '%d %b %Y, %I:%M%p', + date, + ) + + +def init(): + """Initialise all widgets in the given window.""" + pass + + +def init_application(): + """Initialise the standalone application.""" + global window + window = TK_ROOT + init() + + +def init_toplevel(): + """Initialise the window as part of the BEE2.""" + global window + window = tk.Toplevel(TK_ROOT) + init() + + +if __name__ == '__main__': + # Run this standalone. + init_toplevel() + TK_ROOT.deiconify() + TK_ROOT.mainloop() \ No newline at end of file From 6b25bd9506815f43269b9782caaa14471e535df8 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 14:19:55 +1000 Subject: [PATCH 29/91] Add menubar to standalone backup app --- src/Backup.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Backup.py b/src/Backup.py index 2fd866da9..a112bf543 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -12,11 +12,14 @@ from CheckDetails import CheckDetails, Item as CheckItem import utils import tk_tools +import gameMan window = None # type: tk.Toplevel UI = {} +menus = {} # For standalone application, generate menu bars + class P2C: """A PeTI map.""" @@ -54,16 +57,48 @@ def init_application(): window = TK_ROOT init() + UI['bar'] = bar = tk.Menu(TK_ROOT) + window.option_add('*tearOff', False) + + gameMan.load() + + if utils.MAC: + # Name is used to make this the special 'BEE2' menu item + file_menu = menus['file'] = tk.Menu(bar, name='apple') + else: + file_menu = menus['file'] = tk.Menu(bar) + file_menu.add_command(label='New Backup') + file_menu.add_command(label='Open Backup') + file_menu.add_command(label='Save Backup') + file_menu.add_command(label='Save Backup As') + + bar.add_cascade(menu=file_menu, label='File') + + game_menu = menus['game'] = tk.Menu(bar) + + game_menu.add_command(label='Add Game', command=gameMan.add_game) + game_menu.add_command(label='Remove Game', command=gameMan.remove_game) + game_menu.add_separator() + + bar.add_cascade(menu=game_menu, label='Game') + window['menu'] = bar + + gameMan.add_menu_opts(game_menu) + gameMan.game_menu = game_menu + def init_toplevel(): """Initialise the window as part of the BEE2.""" global window window = tk.Toplevel(TK_ROOT) + window.transient(TK_ROOT) + init() if __name__ == '__main__': # Run this standalone. - init_toplevel() + init_application() + TK_ROOT.deiconify() TK_ROOT.mainloop() \ No newline at end of file From dfa580bbd1773716071c72f15870d65b06a9649e Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 14:59:56 +1000 Subject: [PATCH 30/91] Truncate text when needed in order to fit, and show remainder in a tooltip --- src/CheckDetails.py | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index e04b7d8a4..2169e26da 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -6,6 +6,7 @@ via tooltips """ from tkinter import ttk +from tkinter import font import tkinter as tk import functools @@ -17,10 +18,13 @@ UP_ARROW = '\u25B3' DN_ARROW = '\u25BD' +ELLIPSIS = '\u2026' ROW_HEIGHT = 16 ROW_PADDING = 2 +BODY_FONT = font.nametofont('TkDefaultFont') + style = ttk.Style() style.configure( 'CheckDetails.TCheckbutton', @@ -28,6 +32,19 @@ ) +def truncate(text, width): + """Truncate text to fit in the given space.""" + if BODY_FONT.measure(text) < width: + return text # No truncation needed! + + # Chop one character off the end at a time + for ind in range(len(text)-1, 0, -1): + short = text[:ind] + ELLIPSIS + if BODY_FONT.measure(short) < width: + return short + return ELLIPSIS + + class Item: """Represents one item in a CheckDetails list. @@ -62,17 +79,19 @@ def make_widgets(self, master: 'CheckDetails'): command=self.master.update_allcheck, ) - self.val_widgets = [ - tk.Label( + self.val_widgets = [] + for value in self.values: + wid = tk.Label( master.wid_frame, text=value, justify=tk.LEFT, anchor=tk.W, background='white', ) - for value in - self.values - ] + add_tooltip(wid) + wid.tooltip_text = '' + self.val_widgets.append(wid) + def place(self, check_width, head_pos, y): """Position the widgets on the frame.""" @@ -82,13 +101,22 @@ def place(self, check_width, head_pos, y): width=check_width, height=ROW_HEIGHT, ) - for widget, (x, width) in zip(self.val_widgets, head_pos): + for text, widget, (x, width) in zip( + self.values, + self.val_widgets, + head_pos + ): widget.place( x=x+check_width, y=y, width=width, height=ROW_HEIGHT, ) + short_text = widget['text'] = truncate(text, width-5) + if short_text != text: + widget.tooltip_text = text + else: + widget.tooltip_text = '' x += width @property @@ -213,10 +241,12 @@ def make_headers(self): self.wid_head_label[i] = label = ttk.Label( header, + font='TkHeadingFont', text=head_text, ) self.wid_head_sort[i] = sorter = ttk.Label( header, + font='TkHeadingFont', text='', ) label.grid(row=0, column=0, sticky='EW') @@ -377,7 +407,7 @@ def sort(self, index, _=None): Item('Item5', 'Auth3', 'Lorem Ipsum'), Item('Item3', 'Auth2', '.........'), Item('Item4', 'Auth2', '.........'), - Item('Item6', 'Auth4', '.....'), + Item('Item6', 'Sir VeryLongName', '.....'), Item('Item2', 'Auth1', '...'), ] ) From 588009e4765f7c58659e203442890e7d556bca0c Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 15:49:02 +1000 Subject: [PATCH 31/91] Don't directly refer to UI in gameMan This way importing from Backup doesn't require importing UI as well. --- src/UI.py | 2 +- src/gameMan.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/UI.py b/src/UI.py index cfda14d7b..43ed5b80a 100644 --- a/src/UI.py +++ b/src/UI.py @@ -426,7 +426,7 @@ def quit_application(): # Destroy the TK windows TK_ROOT.destroy() exit(0) -gameMan.quit_app = quit_application +gameMan.quit_application = quit_application def load_palette(data): diff --git a/src/gameMan.py b/src/gameMan.py index 52d45c637..f16bcd3ca 100644 --- a/src/gameMan.py +++ b/src/gameMan.py @@ -18,7 +18,6 @@ from BEE2_config import ConfigFile from property_parser import Property import utils -import UI import loadScreen import extract_packages @@ -101,9 +100,19 @@ def translate(string): def setgame_callback(selected_game): + """Callback function run when games are selected.""" pass +def quit_application(): + """Command run to quit the application. + + This is overwritten by UI later. + """ + import sys + sys.exit() + + class Game: def __init__(self, name, steam_id, folder): self.name = name @@ -625,7 +634,7 @@ def load(): # Ask the user for Portal 2's location... if not add_game(refresh_menu=False): # they cancelled, quit - UI.quit_application() + quit_application() loadScreen.main_loader.deiconify() # Show it again selected_game = all_games[0] From d58d08d273a63d77ff71e90a1a903e3d61214f2e Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 15:49:19 +1000 Subject: [PATCH 32/91] Add basic backup window layout --- src/Backup.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index a112bf543..e34ad9c4c 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -20,6 +20,15 @@ menus = {} # For standalone application, generate menu bars +HEADERS = ['Name', 'Mode', 'Date', 'Description'] + +# The game subfolder where puzzles are located +PUZZLE_FOLDERS = { + utils.STEAM_IDS['PORTAL2']: 'portal2', + utils.STEAM_IDS['APTAG']: 'aperturetag', + utils.STEAM_IDS['TWTM']: 'TWTM', +} + class P2C: """A PeTI map.""" @@ -30,10 +39,11 @@ def __init__(self, path, props): self.title = props['title', ''] self.desc = props['description', '...'] self.is_coop = utils.conv_bool(props['coop', '0']) - self.create_time = props['Timestamp_Created', ''] + self.create_time = read_time(props['timestamp_created', '']) + self.mod_time = read_time(props['timestamp_modified', '']) -def read(hex_time): +def read_time(hex_time): """Convert the time format in P2C files into a readable string.""" try: val = int(hex_time, 16) @@ -48,7 +58,65 @@ def read(hex_time): def init(): """Initialise all widgets in the given window.""" - pass + for cat, btn_text in [ + ('back_', 'Restore:'), + ('game_', 'Backup:'), + ]: + UI[cat + 'frame'] = frame = ttk.Frame( + window, + ) + + UI[cat + 'title'] = ttk.Label( + frame, + ) + UI[cat + 'title'].grid(row=0, column=0, sticky='EW') + UI[cat + 'details'] = CheckDetails( + frame, + headers=HEADERS, + ) + UI[cat + 'details'].grid(row=1, column=0, sticky='NSEW') + frame.rowconfigure(1, weight=1) + frame.columnconfigure(0, weight=1) + + button_frame = ttk.Frame( + frame, + ) + button_frame.grid(column=0, row=2) + ttk.Label(button_frame, text=btn_text).grid(row=0, column=0) + UI[cat + 'btn_all'] = ttk.Button( + button_frame, + text='All', + width=3, + ) + UI[cat + 'btn_sel'] = ttk.Button( + button_frame, + text='Selected', + width=8, + ) + UI[cat + 'btn_all'].grid(row=0, column=1) + UI[cat + 'btn_sel'].grid(row=0, column=2) + + UI[cat + 'btn_del'] = ttk.Button( + button_frame, + text='Delete Selected', + width=14, + ) + UI[cat + 'btn_del'].grid(row=1, column=0, columnspan=3) + + utils.add_mousewheel( + UI[cat + 'details'].wid_canvas, + UI[cat + 'frame'], + ) + + UI['back_frame'].grid(row=1, column=0, sticky='NSEW') + ttk.Separator(orient=tk.VERTICAL).grid( + row=1, column=1, sticky='NS', padx=5, + ) + UI['game_frame'].grid(row=1, column=2, sticky='NSEW') + + window.rowconfigure(1, weight=1) + window.columnconfigure(0, weight=1) + window.columnconfigure(2, weight=1) def init_application(): From 18a3103649629bab91e4c10ad444dd4ac184ae08 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 18:29:39 +1000 Subject: [PATCH 33/91] Add a readonly version of ttk.Entry This should fix a bug with selector textboxes on OSX --- src/selectorWin.py | 2 +- src/tk_tools.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/selectorWin.py b/src/selectorWin.py index 15af91259..bda75c0f4 100644 --- a/src/selectorWin.py +++ b/src/selectorWin.py @@ -503,7 +503,7 @@ def widget(self, frame) -> ttk.Entry: and place the textbox. """ - self.display = ttk.Entry( + self.display = tk_tools.ReadOnlyEntry( frame, textvariable=self.disp_label, cursor=utils.CURSORS['regular'], diff --git a/src/tk_tools.py b/src/tk_tools.py index 988c045db..f66be025c 100644 --- a/src/tk_tools.py +++ b/src/tk_tools.py @@ -4,6 +4,9 @@ """ from tkinter import ttk import tkinter as tk + +from idlelib.WidgetRedirector import WidgetRedirector + import utils # Put this in a module so it's a singleton, and we can always import the same @@ -16,6 +19,11 @@ TK_ROOT.withdraw() # Hide the window until everything is loaded. +def event_cancel(*args, **kwargs): + """Bind to an event to cancel it, and prevent it from propagating.""" + return 'break' + + class HidingScroll(ttk.Scrollbar): """A scrollbar variant which auto-hides when not needed. @@ -28,3 +36,21 @@ def set(self, low, high): else: self.grid() super(HidingScroll, self).set(low, high) + + +class ReadOnlyEntry(ttk.Entry): + """A modified Entry widget which prevents editing the text. + + See http://tkinter.unpythonic.net/wiki/ReadOnlyText + """ + def __init__(self, master, **opt): + + opt['exportselection'] = 0 # Don't let it write to clipboard + opt['takefocus'] = 0 # Ignore when tabbing + super().__init__(master, **opt) + + self.redirector = redir = WidgetRedirector(self) + # These two TK commands are used for all text operations, + # so cancelling them stops anything from happening. + self.insert = redir.register('insert', event_cancel) + self.delete = redir.register('delete', event_cancel) \ No newline at end of file From 1cfe98ce9d34cd370daf2a08cbc965cd87af2d5f Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 18:30:27 +1000 Subject: [PATCH 34/91] Add backup to BEE2 menus, add more UI load steps - This increases the granularity of the loading bar. --- src/BEE2.pyw | 2 +- src/Backup.py | 32 +++++++++++++++++++++++++++++++- src/UI.py | 14 +++++++++++--- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/BEE2.pyw b/src/BEE2.pyw index cc9a97d91..1c00d5915 100644 --- a/src/BEE2.pyw +++ b/src/BEE2.pyw @@ -62,7 +62,7 @@ if __name__ == '__main__': 'log_missing_ent_count': '0', }, } - loadScreen.main_loader.set_length('UI', 9) + loadScreen.main_loader.set_length('UI', 13) loadScreen.main_loader.show() if utils.MAC: diff --git a/src/Backup.py b/src/Backup.py index e34ad9c4c..5c4a548ff 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -56,6 +56,12 @@ def read_time(hex_time): ) +def show_window(): + window.deiconify() + window.lift() + utils.center_win(window, TK_ROOT) + + def init(): """Initialise all widgets in the given window.""" for cat, btn_text in [ @@ -155,13 +161,37 @@ def init_application(): gameMan.game_menu = game_menu +def init_backup_settings(): + """Initialise the auto-backup settings widget.""" + UI['auto_frame'] = frame = ttk.LabelFrame( + window, + ) + UI['auto_enable'] = enable_check = ttk.Checkbutton( + frame, + text='Automatic Backup After Export', + ) + frame['labelwidget'] = enable_check + frame.grid(row=2, column=0, columnspan=3) + + UI['auto_dir'] = tk_tools.ReadOnlyEntry(frame) + + UI['auto_dir'].grid(row=0, column=0) + + + + def init_toplevel(): """Initialise the window as part of the BEE2.""" global window window = tk.Toplevel(TK_ROOT) window.transient(TK_ROOT) + window.withdraw() + + # Don't destroy window when quit! + window.protocol("WM_DELETE_WINDOW", window.withdraw) init() + init_backup_settings() if __name__ == '__main__': @@ -169,4 +199,4 @@ def init_toplevel(): init_application() TK_ROOT.deiconify() - TK_ROOT.mainloop() \ No newline at end of file + TK_ROOT.mainloop() diff --git a/src/UI.py b/src/UI.py index 43ed5b80a..e2ab0f0ce 100644 --- a/src/UI.py +++ b/src/UI.py @@ -26,6 +26,7 @@ import CompilerPane import tagsPane import optionWindow +import backup as backupWin import tooltip @@ -1452,7 +1453,7 @@ def init_menu_bar(win): # This will be enabled when the resources have been unpacked state=DISABLED, ) - file_menu.export_btn_index = 0 # Change this if the menu is reordered + file_menu.export_btn_index = 0 # Change this if the menu is reordered file_menu.add_command( label="Add Game", @@ -1462,6 +1463,10 @@ def init_menu_bar(win): label="Remove Selected Game", command=gameMan.remove_game, ) + file_menu.add_command( + label="Backup/Restore Puzzles", + command=backupWin.show_window, + ) file_menu.add_separator() file_menu.add_command( label="Options", @@ -1560,8 +1565,6 @@ def init_windows(): ) # Prevent making the window smaller than the preview pane loader.step('UI') - loader.step('UI') - ttk.Separator( ui_bg, orient=VERTICAL, @@ -1677,9 +1680,14 @@ def init_windows(): utils.bind_leftclick(windows['opt'], contextWin.hide_context) utils.bind_leftclick(windows['pal'], contextWin.hide_context) + backupWin.init_toplevel() + loader.step('UI') voiceEditor.init_widgets() + loader.step('UI') contextWin.init_widgets() + loader.step('UI') optionWindow.init_widgets() + loader.step('UI') init_drag_icon() loader.step('UI') From f6f9eba6a4a89464c2bebb7ed2f91f79cbf04f0b Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 18:37:02 +1000 Subject: [PATCH 35/91] Exclude modules we don't need, to decrease file size --- src/compile_BEE2.py | 79 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/compile_BEE2.py b/src/compile_BEE2.py index db7542796..112e953d5 100644 --- a/src/compile_BEE2.py +++ b/src/compile_BEE2.py @@ -6,6 +6,84 @@ ico_path = os.path.join(os.getcwd(), "../bee2.ico") +# Exclude bits of modules we don't need, to decrease package size. +EXCLUDES = [ + # Just using the core and .mixer + 'pygame.math', + 'pygame.cdrom', + 'pygame.cursors', + 'pygame.display', + 'pygame.draw', + 'pygame.event', + 'pygame.image', + 'pygame.joystick', + 'pygame.key', + 'pygame.mouse', + 'pygame.sprite', + 'pygame.threads', + 'pygame.pixelcopy', + 'pygame.mask', + 'pygame.pixelarray', + 'pygame.overlay', + 'pygame.time', + 'pygame.transform', + 'pygame.font', + 'pygame.sysfont', + 'pygame.movie', + 'pygame.movieext', + 'pygame.scrap', + 'pygame.surfarray', + 'pygame.sndarray', + 'pygame.fastevent', + + # We just use idlelib.WidgetRedirector + 'idlelib.ClassBrowser', + 'idlelib.ColorDelegator', + 'idlelib.Debugger', + 'idlelib.Delegator', + 'idlelib.EditorWindow', + 'idlelib.FileList', + 'idlelib.GrepDialog', + 'idlelib.IOBinding', + 'idlelib.IdleHistory', + 'idlelib.MultiCall', + 'idlelib.MultiStatusBar', + 'idlelib.ObjectBrowser', + 'idlelib.OutputWindow', + 'idlelib.PathBrowser', + 'idlelib.Percolator', + 'idlelib.PyParse', + 'idlelib.PyShell', + 'idlelib.RemoteDebugger', + 'idlelib.RemoteObjectBrowser', + 'idlelib.ReplaceDialog', + 'idlelib.ScrolledList', + 'idlelib.SearchDialog', + 'idlelib.SearchDialogBase', + 'idlelib.SearchEngine', + 'idlelib.StackViewer', + 'idlelib.TreeWidget', + 'idlelib.UndoDelegator', + 'idlelib.WindowList', + 'idlelib.ZoomHeight', + 'idlelib.aboutDialog', + 'idlelib.configDialog', + 'idlelib.configHandler', + 'idlelib.configHelpSourceEdit', + 'idlelib.configSectionNameDialog', + 'idlelib.dynOptionMenuWidget', + 'idlelib.idle_test.htest', + 'idlelib.idlever', + 'idlelib.keybindingDialog', + 'idlelib.macosxSupport', + 'idlelib.rpc', + 'idlelib.tabbedpages', + 'idlelib.textView', + + # Stop us from then including Qt itself + 'PIL.ImageQt', +] + if utils.WIN: base = 'Win32GUI' else: @@ -18,6 +96,7 @@ options={ 'build_exe': { 'build_exe': '../build_BEE2/bin', + 'excludes': EXCLUDES, }, }, executables=[ From 4d0a78dc461b0a0e5ab63b2938f0520054621541 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 14 Nov 2015 18:49:52 +1000 Subject: [PATCH 36/91] Exclude a number of modules from vbsp/vrad as well --- src/compile_vbsp_vrad.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/compile_vbsp_vrad.py b/src/compile_vbsp_vrad.py index c6f5f68df..902287b7a 100644 --- a/src/compile_vbsp_vrad.py +++ b/src/compile_vbsp_vrad.py @@ -11,13 +11,21 @@ elif utils.LINUX: suffix = '_linux' +# Unneeded packages that cx_freeze detects: +EXCLUDES = [ + 'email', + 'distutils', # Found in shutil, used if zipfile is not availible + 'doctest', # Used in __main__ of decimal and heapq + 'dis', # From inspect, not needed +] setup( name='VBSP_VRAD', version='0.1', options={ 'build_exe': { - 'build_exe': '../compiler' + 'build_exe': '../compiler', + 'excludes': EXCLUDES, } }, description='BEE2 VBSP and VRAD compilation hooks, ' From 62bf01e5fe42b8cb051dd69dc0ebdd18b9c52b60 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 09:22:02 +1000 Subject: [PATCH 37/91] Add hooks when any or no items are checked , and correctly update checkboxes --- src/CheckDetails.py | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 2169e26da..c01d9c07e 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -31,6 +31,11 @@ background='white', ) +# An event generated when items are all unchecked. +# Use to disable buttons when needed +EVENT_NO_CHECKS = '<>' +EVENT_HAS_CHECKS = '<>' + def truncate(text, width): """Truncate text to fit in the given space.""" @@ -227,8 +232,7 @@ def checkbox_leave(e): self.wid_header.bind('', self.refresh) self.wid_header.bind('', self.refresh) - for item in items: - self.add_item(item) + self.add_items(*items) def make_headers(self): """Generate the heading widgets.""" @@ -276,33 +280,46 @@ def header_leave(_, label=label, sorter=sorter): sorter['text'] = '' - def add_item(self, item): - self.items.append(item) - item.make_widgets(self) + def add_items(self, *items): + for item in items: + self.items.append(item) + item.make_widgets(self) + self.update_allcheck() + self.refresh() - def rem_item(self, item): - self.items.remove(item) + def rem_items(self, *items): + for item in items: + self.items.remove(item) + self.update_allcheck() + self.refresh() def update_allcheck(self): """Update the 'all' checkbox to match the state of sub-boxes.""" num_checked = sum(item.state for item in self.items) if num_checked == 0: self.head_check_var.set(False) + self.event_generate(EVENT_NO_CHECKS) elif num_checked == len(self.items): self.wid_head_check.state(['!alternate']) self.head_check_var.set(True) self.wid_head_check.state(['!alternate']) + self.event_generate(EVENT_HAS_CHECKS) else: # The 'half' state is just visual. # Set to true so everything is blanked when next clicking self.head_check_var.set(True) self.wid_head_check.state(['alternate']) + self.event_generate(EVENT_HAS_CHECKS) def toggle_allcheck(self): value = self.head_check_var.get() for item in self.items: # Bypass the update function item.state_var.set(value) + if value and self.items: # Don't enable if we don't have items + self.event_generate(EVENT_HAS_CHECKS) + else: + self.event_generate(EVENT_NO_CHECKS) def refresh(self, _=None): """Reposition the widgets. @@ -324,6 +341,12 @@ def refresh(self, _=None): item.place(check_width, header_sizes, pos) pos += ROW_HEIGHT + ROW_PADDING + # Disable checkbox if no items are present + if self.items: + self.wid_head_check.state(['!disabled']) + else: + self.wid_head_check.state(['disabled']) + self.wid_frame['width'] = width = max( self.wid_canvas.winfo_width(), sum(header_sizes[-1]) + check_width, From 2da4e08336a6d2ec8b9c5e5009c9fed8d6021688 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 10:00:57 +1000 Subject: [PATCH 38/91] Add ability to for property_parser to handle multiline strings PeTI maps can include this in the description, so this needs to be handled. --- src/CheckDetails.py | 26 +++++++++++++++++++------- src/property_parser.py | 36 +++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index c01d9c07e..c8ed84f66 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -54,11 +54,12 @@ class Item: """Represents one item in a CheckDetails list. """ - def __init__(self, *values): + def __init__(self, *values, hover_text=None): self.values = values self.state_var = tk.IntVar(value=0) self.master = None # type: CheckDetails self.check = None # type: ttk.Checkbutton + self.hover_text = hover_text self.val_widgets = [] def copy(self): @@ -94,10 +95,14 @@ def make_widgets(self, master: 'CheckDetails'): background='white', ) add_tooltip(wid) - wid.tooltip_text = '' + if self.hover_text: + wid.tooltip_text = self.hover_text + wid.hover_override = True + else: + wid.tooltip_text = '' + wid.hover_override = False self.val_widgets.append(wid) - def place(self, check_width, head_pos, y): """Position the widgets on the frame.""" self.check.place( @@ -118,10 +123,11 @@ def place(self, check_width, head_pos, y): height=ROW_HEIGHT, ) short_text = widget['text'] = truncate(text, width-5) - if short_text != text: - widget.tooltip_text = text - else: - widget.tooltip_text = '' + if not widget.hover_override: + if short_text != text: + widget.tooltip_text = text + else: + widget.tooltip_text = '' x += width @property @@ -293,6 +299,12 @@ def rem_items(self, *items): self.update_allcheck() self.refresh() + def remove_all(self): + """Remove all items from the list.""" + self.items.clear() + self.update_allcheck() + self.refresh() + def update_allcheck(self): """Update the 'all' checkbox to match the state of sub-boxes.""" num_checked = sum(item.state for item in self.items) diff --git a/src/property_parser.py b/src/property_parser.py index 3c861cd25..db4564a82 100644 --- a/src/property_parser.py +++ b/src/property_parser.py @@ -27,6 +27,7 @@ _Prop_Value = Union[List['Property'], str] _as_dict_return = Dict[str, Union[str, 'as_dict_return']] + class KeyValError(Exception): """An error that occured when parsing a Valve KeyValues file. @@ -85,6 +86,24 @@ def __str__(self): return "No key " + self.key + "!" +def read_multiline_value(file, line_num, filename): + """Pull lines out until a quote character is reached.""" + lines = [''] # We return with a beginning newline + # Re-looping over the same iterator means we don't repeat lines + for line_num, line in file: + line = utils.clean_line(line) + if line.endswith('"'): + lines.append(line[:-1]) + return '\n'.join(lines) + else: + # We hit EOF! + raise KeyValError( + "Reached EOF without ending quote!", + filename, + line_num, + ) + + class Property: """Represents Property found in property files, like those used by Valve. @@ -145,7 +164,8 @@ def parse(file_contents, filename='') -> "Property": file_contents should be an iterable of strings """ open_properties = [Property(None, [])] - for line_num, line in enumerate(file_contents, start=1): + file_iter = enumerate(file_contents, start=1) + for line_num, line in file_iter: values = open_properties[-1].value freshline = utils.clean_line(line) if not freshline: @@ -163,16 +183,18 @@ def parse(file_contents, filename='') -> "Property": ) try: value = line_contents[3] + except IndexError: + value = None + else: if not freshline.endswith('"'): - raise KeyValError( - 'Key has value, but incomplete quotes!', - filename, + # It's a multiline value! + value += read_multiline_value( + file_iter, line_num, - ) + filename, + ) for orig, new in REPLACE_CHARS.items(): value = value.replace(orig, new) - except IndexError: - value = None values.append(Property(name, value)) elif freshline.startswith('{'): From 8ffd281a401ba1eb6618d0005a92e79d34e3f7d5 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 10:15:50 +1000 Subject: [PATCH 39/91] Allow passing non-strings to CheckDetails items This is used for allowing special sorting. --- src/CheckDetails.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index c8ed84f66..4d25c1033 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -122,6 +122,7 @@ def place(self, check_width, head_pos, y): width=width, height=ROW_HEIGHT, ) + text = str(text) short_text = widget['text'] = truncate(text, width-5) if not widget.hover_override: if short_text != text: From 5e9810ed5ad6918fd9f3d4f720898fea27f1ff99 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 10:16:25 +1000 Subject: [PATCH 40/91] Load game backups, and sort dates correctly --- src/Backup.py | 201 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 176 insertions(+), 25 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index 5c4a548ff..69c3505fe 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -5,22 +5,29 @@ from tkinter import ttk from tk_tools import TK_ROOT +from datetime import datetime import time +import os + +from FakeZip import FakeZip, zip_names from zipfile import ZipFile +from tooltip import add_tooltip from property_parser import Property from CheckDetails import CheckDetails, Item as CheckItem +import img import utils import tk_tools import gameMan + window = None # type: tk.Toplevel UI = {} menus = {} # For standalone application, generate menu bars -HEADERS = ['Name', 'Mode', 'Date', 'Description'] +HEADERS = ['Name', 'Mode', 'Date'] # The game subfolder where puzzles are located PUZZLE_FOLDERS = { @@ -29,31 +36,145 @@ utils.STEAM_IDS['TWTM']: 'TWTM', } +# The currently-loaded backup files. +BACKUPS = { + 'game': [], + 'back': [], +} + class P2C: """A PeTI map.""" - def __init__(self, path, props): - props = Property.parse(props) + def __init__(self, path, zip_file): + """Initialise the map. + + path is the file path for the map inside the zip, without extension. + zip_file is either a ZipFile or FakeZip object. + """ + with zip_file.open(path + '.p2c') as file: + props = Property.parse(file, path) + props = props.find_key('portal2_puzzle', []) self.path = path - self.title = props['title', ''] + self.zip_file = zip_file + self.title = props['title', None] + if self.title is None: + self.title = '<' + path.rsplit('/', 1)[-1] + '.p2c>' self.desc = props['description', '...'] self.is_coop = utils.conv_bool(props['coop', '0']) - self.create_time = read_time(props['timestamp_created', '']) - self.mod_time = read_time(props['timestamp_modified', '']) - - -def read_time(hex_time): - """Convert the time format in P2C files into a readable string.""" - try: - val = int(hex_time, 16) - except ValueError: - return '??' - date = time.localtime(val) - return time.strftime( - '%d %b %Y, %I:%M%p', - date, - ) + self.create_time = Date(props['timestamp_created', '']) + self.mod_time = Date(props['timestamp_modified', '']) + + def make_item(self): + """Make a corresponding CheckItem object.""" + return CheckItem( + self.title, + ('COOP' if self.is_coop else 'SP'), + self.mod_time, + hover_text=self.desc + ) + + +class Date: + """A version of datetime with an invalid value, and read from hex. + """ + def __init__(self, hex_time): + """Convert the time format in P2C files into a useable value.""" + try: + val = int(hex_time, 16) + except ValueError: + self.date = None + else: + self.date = datetime.fromtimestamp(val) + + def __str__(self): + """Return value for display.""" + if self.date is None: + return '???' + else: + return time.strftime( + '%d %b %Y, %I:%M%p', + self.date.timetuple(), + ) + + # No date = always earlier + def __lt__(self, other): + if self.date is None: + return True + else: + return self.date < other.date + + def __gt__(self, other): + if self.date is None: + return False + else: + return self.date > other.date + + def __le__(self, other): + if self.date is None: + return other.date is None + else: + return self.date <= other.date + + def __ge__(self, other): + if self.date is None: + return other.date is None + else: + return self.date >= other.date + + def __eq__(self, other): + return self.date == other.date + + def __ne__(self, other): + return self.date != other.date + + +# Note: All the backup functions use zip files, but also work on FakeZip +# directories. + + +def load_backup(zip_file): + """Load in a backup file.""" + maps = [] + for file in zip_names(zip_file): + if file.endswith('.p2c'): + bare_file = file[:-4] + maps.append(P2C(bare_file, zip_file)) + return maps + + +def load_game(game: gameMan.Game): + """Callback for gameMan, load in files for a game.""" + puzzle_folder = PUZZLE_FOLDERS.get(str(game.steamID), 'portal2') + path = game.abs_path(puzzle_folder + '/puzzles/') + for folder in os.listdir(path): + if not folder.isdigit(): + continue + abs_path = os.path.join(path, folder) + if os.path.isdir(abs_path): + zip_file = FakeZip(abs_path) + maps = load_backup(zip_file) + BACKUPS['game'] = maps + refresh_details() + + +def refresh_details(): + """Remake the items in the checkdetails list.""" + game = UI['game_details'] + game.remove_all() + game.add_items(*( + peti_map.make_item() + for peti_map in + BACKUPS['game'] + )) + + backup = UI['back_details'] + backup.remove_all() + backup.add_items(*( + peti_map.make_item() + for peti_map in + BACKUPS['back'] + )) def show_window(): @@ -62,6 +183,17 @@ def show_window(): utils.center_win(window, TK_ROOT) +def ui_load_backup(): + """Prompt and load in a backup file.""" + pass + + +def ui_refresh_game(): + """Reload the game maps list.""" + if gameMan.selected_game is not None: + load_game(gameMan.selected_game) + + def init(): """Initialise all widgets in the given window.""" for cat, btn_text in [ @@ -71,11 +203,17 @@ def init(): UI[cat + 'frame'] = frame = ttk.Frame( window, ) - - UI[cat + 'title'] = ttk.Label( + UI[cat + 'title_frame'] = title_frame = ttk.Frame( frame, ) + title_frame.grid(row=0, column=0, sticky='EW') + UI[cat + 'title'] = ttk.Label( + title_frame, + ) UI[cat + 'title'].grid(row=0, column=0, sticky='EW') + title_frame.rowconfigure(0, weight=1) + title_frame.columnconfigure(0, weight=1) + UI[cat + 'details'] = CheckDetails( frame, headers=HEADERS, @@ -96,7 +234,7 @@ def init(): ) UI[cat + 'btn_sel'] = ttk.Button( button_frame, - text='Selected', + text='Checked', width=8, ) UI[cat + 'btn_all'].grid(row=0, column=1) @@ -104,7 +242,7 @@ def init(): UI[cat + 'btn_del'] = ttk.Button( button_frame, - text='Delete Selected', + text='Delete Checked', width=14, ) UI[cat + 'btn_del'].grid(row=1, column=0, columnspan=3) @@ -114,6 +252,17 @@ def init(): UI[cat + 'frame'], ) + UI['game_refresh'] = ttk.Button( + UI['game_title_frame'], + image=img.png('icons/tool_sub'), + command=ui_refresh_game, + ) + UI['game_refresh'].grid(row=0, column=1, sticky='E') + add_tooltip( + UI['game_refresh'], + "Reload the map list.", + ) + UI['back_frame'].grid(row=1, column=0, sticky='NSEW') ttk.Separator(orient=tk.VERTICAL).grid( row=1, column=1, sticky='NS', padx=5, @@ -136,6 +285,8 @@ def init_application(): gameMan.load() + ui_refresh_game() + if utils.MAC: # Name is used to make this the special 'BEE2' menu item file_menu = menus['file'] = tk.Menu(bar, name='apple') @@ -178,8 +329,6 @@ def init_backup_settings(): UI['auto_dir'].grid(row=0, column=0) - - def init_toplevel(): """Initialise the window as part of the BEE2.""" global window @@ -193,6 +342,8 @@ def init_toplevel(): init() init_backup_settings() + ui_refresh_game() + if __name__ == '__main__': # Run this standalone. From 0034b29034a18bfad5471d1e2ead425695c53ce4 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 10:38:25 +1000 Subject: [PATCH 41/91] Make bind_leftclick/rightclick accept 'add' commands --- src/utils.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/utils.py b/src/utils.py index 55ac0941e..26aefd4da 100644 --- a/src/utils.py +++ b/src/utils.py @@ -206,7 +206,7 @@ def scroll_down(_): if MAC: # On OSX, make left-clicks switch to a rightclick when control is held. - def bind_leftclick(wid, func): + def bind_leftclick(wid, func, add='+'): """On OSX, left-clicks are converted to right-clicks when control is held. @@ -216,9 +216,9 @@ def event_handler(e): # Don't run the event if control is held! if e.state & 4 == 0: func() - wid.bind(EVENTS['LEFT'], event_handler) + wid.bind(EVENTS['LEFT'], event_handler, add=add) - def bind_leftclick_double(wid, func): + def bind_leftclick_double(wid, func, add='+'): """On OSX, left-clicks are converted to right-clicks when control is held.""" @@ -227,24 +227,24 @@ def event_handler(e): # Don't run the event if control is held! if e.state & 4 == 0: func() - wid.bind(EVENTS['LEFT_DOUBLE'], event_handler) + wid.bind(EVENTS['LEFT_DOUBLE'], event_handler, add=add) def bind_rightclick(wid, func): """On OSX, we need to bind to both rightclick and control-leftclick.""" wid.bind(EVENTS['RIGHT'], func) wid.bind(EVENTS['LEFT_CTRL'], func) else: - def bind_leftclick(wid, func): + def bind_leftclick(wid, func, add='+'): """Other systems just bind directly.""" - wid.bind(EVENTS['LEFT'], func) + wid.bind(EVENTS['LEFT'], func, add=add) - def bind_leftclick_double(wid, func): + def bind_leftclick_double(wid, func, add='+'): """Other systems just bind directly.""" - wid.bind(EVENTS['LEFT_DOUBLE'], func) + wid.bind(EVENTS['LEFT_DOUBLE'], func, add=add) - def bind_rightclick(wid, func): + def bind_rightclick(wid, func, add='+'): """Other systems just bind directly.""" - wid.bind(EVENTS['RIGHT'], func) + wid.bind(EVENTS['RIGHT'], func, add=add) USE_SIZEGRIP = not MAC # On Mac, we don't want to use the sizegrip widget From 42e9c3aa228e447ff4a3a07c54bf4fb39f6975a1 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 10:40:18 +1000 Subject: [PATCH 42/91] Make multiline values actually work --- src/property_parser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/property_parser.py b/src/property_parser.py index db4564a82..98ac67f42 100644 --- a/src/property_parser.py +++ b/src/property_parser.py @@ -95,6 +95,7 @@ def read_multiline_value(file, line_num, filename): if line.endswith('"'): lines.append(line[:-1]) return '\n'.join(lines) + lines.append(line) else: # We hit EOF! raise KeyValError( From f18c05576c883acb19bef4002702819c238e6fb8 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 10:42:37 +1000 Subject: [PATCH 43/91] Allow clicking anywhere in a row to toggle the checkbox --- src/CheckDetails.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 4d25c1033..3f8d997c2 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -101,8 +101,21 @@ def make_widgets(self, master: 'CheckDetails'): else: wid.tooltip_text = '' wid.hover_override = False + + # Allow clicking on the row to toggle the checkbox + wid.bind('', self.hover_start, add='+') + wid.bind('', self.hover_stop, add='+') + utils.bind_leftclick(wid, self.row_click, add='+') + wid.bind(utils.EVENTS['LEFT_RELEASE'], self.row_unclick, add='+') + self.val_widgets.append(wid) + utils.add_mousewheel( + self.master.wid_canvas, + self.check, + *self.val_widgets + ) + def place(self, check_width, head_pos, y): """Position the widgets on the frame.""" self.check.place( @@ -140,6 +153,19 @@ def state(self, value: bool): self.state_var.set(value) self.master.update_allcheck() + def hover_start(self, e): + self.check.state(['active']) + + def hover_stop(self, e): + self.check.state(['!active']) + + def row_click(self, e): + self.state = not self.state + self.check.state(['pressed']) + + def row_unclick(self, e): + self.check.state(['!pressed']) + class CheckDetails(ttk.Frame): def __init__(self, parent, items=(), headers=(), add_sizegrip=False): @@ -241,6 +267,14 @@ def checkbox_leave(e): self.add_items(*items) + utils.add_mousewheel( + self.wid_canvas, + + self.wid_canvas, + self.wid_frame, + self.wid_header, + ) + def make_headers(self): """Generate the heading widgets.""" @@ -340,7 +374,6 @@ def refresh(self, _=None): Must be called when self.items is changed, or when window is resized. """ - self.wid_header.update_idletasks() header_sizes = [ (head.winfo_x(), head.winfo_width()) for head in From fac578cc1965215d7a8ea82b13d51590865cb82e Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 10:44:51 +1000 Subject: [PATCH 44/91] Fix invisible map text, and change capitalisation of 'COOP'. --- src/Backup.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Backup.py b/src/Backup.py index 69c3505fe..04d1432ee 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -69,7 +69,7 @@ def make_item(self): """Make a corresponding CheckItem object.""" return CheckItem( self.title, - ('COOP' if self.is_coop else 'SP'), + ('Coop' if self.is_coop else 'SP'), self.mod_time, hover_text=self.desc ) @@ -350,4 +350,12 @@ def init_toplevel(): init_application() TK_ROOT.deiconify() + + def fix_details(): + # It takes a while before the detail headers update positions, + # so delay a refresh call. + TK_ROOT.update_idletasks() + UI['game_details'].refresh() + TK_ROOT.after(500, fix_details) + TK_ROOT.mainloop() From 7ff1049ae59f19b7086b062e9259c729abd41af4 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 11:01:03 +1000 Subject: [PATCH 45/91] Properly destroy removed item rows --- src/CheckDetails.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 3f8d997c2..c401dd384 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -144,6 +144,12 @@ def place(self, check_width, head_pos, y): widget.tooltip_text = '' x += width + def destroy(self): + """Remove this from the window.""" + self.check.place_forget() + for wid in self.val_widgets: + wid.place_forget() + @property def state(self) -> bool: return self.state_var.get() @@ -331,11 +337,14 @@ def add_items(self, *items): def rem_items(self, *items): for item in items: self.items.remove(item) + item.destroy() self.update_allcheck() self.refresh() def remove_all(self): """Remove all items from the list.""" + for item in self.items: + item.destroy() self.items.clear() self.update_allcheck() self.refresh() From b036385e9510d85c1177684adf6fb01d9c592d3a Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 11:23:23 +1000 Subject: [PATCH 46/91] LoadScreen improvements - Add context manager support - Allow blank stage captions --- src/loadScreen.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/loadScreen.py b/src/loadScreen.py index 8389adfe8..c3d1f3430 100644 --- a/src/loadScreen.py +++ b/src/loadScreen.py @@ -48,15 +48,17 @@ def __init__(self, *stages, title_text='Loading'): ).grid(row=1, sticky="EW", columnspan=2) for ind, (st_id, stage_name) in enumerate(self.stages): - ttk.Label( - self.frame, - text=stage_name + ':', - cursor=utils.CURSORS['wait'], - ).grid( - row=ind*2+2, - columnspan=2, - sticky="W", - ) + if stage_name: + # If stage name is blank, don't add a caption + ttk.Label( + self.frame, + text=stage_name + ':', + cursor=utils.CURSORS['wait'], + ).grid( + row=ind*2+2, + columnspan=2, + sticky="W", + ) self.bar_var[st_id] = IntVar() self.bar_val[st_id] = 0 self.maxes[st_id] = 10 @@ -134,6 +136,19 @@ def destroy(self): del self.bar_val self.active = False + def __enter__(self): + """LoadScreen can be used as a context manager. + + Inside the block, the screen will be visible. + """ + self.show() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Hide the loading screen, and passthrough execptions. + """ + self.reset() + main_loader = LoadScreen( ('PAK', 'Packages'), ('OBJ', 'Loading Objects'), From 35d5936bad6d3a30b17dbe3718d476a88d4f91de Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 11:27:00 +1000 Subject: [PATCH 47/91] Add progress screen when switching games - It can take some time to load in game properties if there are many maps. --- src/Backup.py | 69 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index 04d1432ee..460ff845c 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -3,6 +3,7 @@ """ import tkinter as tk from tkinter import ttk + from tk_tools import TK_ROOT from datetime import datetime @@ -15,6 +16,7 @@ from tooltip import add_tooltip from property_parser import Property from CheckDetails import CheckDetails, Item as CheckItem +from loadScreen import LoadScreen import img import utils import tk_tools @@ -40,8 +42,26 @@ BACKUPS = { 'game': [], 'back': [], + + # The name of the current backup file + 'backup_path': None, } +# Variables associated with the heading text. +backup_name = tk.StringVar() +game_name = tk.StringVar() + +copy_loader = LoadScreen( + ('COPY', ''), + title_text='Copying maps', +) + +reading_loader = LoadScreen( + ('READ', ''), + title_text='Loading maps', +) + + class P2C: """A PeTI map.""" @@ -136,15 +156,27 @@ def __ne__(self, other): def load_backup(zip_file): """Load in a backup file.""" maps = [] - for file in zip_names(zip_file): - if file.endswith('.p2c'): - bare_file = file[:-4] - maps.append(P2C(bare_file, zip_file)) + puzzles = [ + file[:-4] # Strip extension + for file in + zip_names(zip_file) + if file.endswith('.p2c') + ] + # Each P2C init requires reading in the properties file, so this may take + # some time. Use a loading screen. + reading_loader.set_length('READ', len(puzzles)) + with reading_loader: + for file in puzzles: + maps.append(P2C(file, zip_file)) + reading_loader.step('READ') + return maps def load_game(game: gameMan.Game): """Callback for gameMan, load in files for a game.""" + game_name.set(game.name) + puzzle_folder = PUZZLE_FOLDERS.get(str(game.steamID), 'portal2') path = game.abs_path(puzzle_folder + '/puzzles/') for folder in os.listdir(path): @@ -155,11 +187,11 @@ def load_game(game: gameMan.Game): zip_file = FakeZip(abs_path) maps = load_backup(zip_file) BACKUPS['game'] = maps - refresh_details() + refresh_game_details() -def refresh_details(): - """Remake the items in the checkdetails list.""" +def refresh_game_details(): + """Remake the items in the game maps list.""" game = UI['game_details'] game.remove_all() game.add_items(*( @@ -168,6 +200,9 @@ def refresh_details(): BACKUPS['game'] )) + +def refresh_back_details(): + """Remake the items in the backup list.""" backup = UI['back_details'] backup.remove_all() backup.add_items(*( @@ -188,6 +223,13 @@ def ui_load_backup(): pass +def ui_new_backup(): + """Create a new backup file.""" + BACKUPS['back'].clear() + BACKUPS['backup_name'] = None + backup_name.set('Unsaved Backup') + + def ui_refresh_game(): """Reload the game maps list.""" if gameMan.selected_game is not None: @@ -209,8 +251,9 @@ def init(): title_frame.grid(row=0, column=0, sticky='EW') UI[cat + 'title'] = ttk.Label( title_frame, + font='TkHeadingFont', ) - UI[cat + 'title'].grid(row=0, column=0, sticky='EW') + UI[cat + 'title'].grid(row=0, column=0) title_frame.rowconfigure(0, weight=1) title_frame.columnconfigure(0, weight=1) @@ -263,6 +306,9 @@ def init(): "Reload the map list.", ) + UI['game_title']['textvariable'] = game_name + UI['back_title']['textvariable'] = backup_name + UI['back_frame'].grid(row=1, column=0, sticky='NSEW') ttk.Separator(orient=tk.VERTICAL).grid( row=1, column=1, sticky='NS', padx=5, @@ -284,15 +330,17 @@ def init_application(): window.option_add('*tearOff', False) gameMan.load() + ui_new_backup() - ui_refresh_game() + # UI.py isn't present, so we use this callback + gameMan.setgame_callback = load_game if utils.MAC: # Name is used to make this the special 'BEE2' menu item file_menu = menus['file'] = tk.Menu(bar, name='apple') else: file_menu = menus['file'] = tk.Menu(bar) - file_menu.add_command(label='New Backup') + file_menu.add_command(label='New Backup', command=ui_new_backup) file_menu.add_command(label='Open Backup') file_menu.add_command(label='Save Backup') file_menu.add_command(label='Save Backup As') @@ -343,6 +391,7 @@ def init_toplevel(): init_backup_settings() ui_refresh_game() + ui_new_backup() if __name__ == '__main__': From 8e20db8ef9c37afda1abffc7d9c3b560a186ca4a Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 11:29:25 +1000 Subject: [PATCH 48/91] Fix orderability of dates --- src/Backup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Backup.py b/src/Backup.py index 460ff845c..fec6d8064 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -121,12 +121,16 @@ def __str__(self): def __lt__(self, other): if self.date is None: return True + elif other.date is None: + return False else: return self.date < other.date def __gt__(self, other): if self.date is None: return False + elif other.date is None: + return True else: return self.date > other.date From 0434516d33749194cb954efabfd0d19881600d53 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 13:04:39 +1000 Subject: [PATCH 49/91] Use mode in FakeZip.open() --- src/FakeZip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FakeZip.py b/src/FakeZip.py index 28e1fe4fd..4a4f6efb6 100644 --- a/src/FakeZip.py +++ b/src/FakeZip.py @@ -52,7 +52,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def open(self, name, mode='r', pwd=None): try: - return open(os.path.join(self.folder, name)) + return open(os.path.join(self.folder, name), mode) except FileNotFoundError as err: raise KeyError from err # This is what zips raise From 029365dfeeda6251b696dd2d15853b342b96b4b9 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 14:53:44 +1000 Subject: [PATCH 50/91] Backup saving, loading --- src/Backup.py | 216 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 196 insertions(+), 20 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index fec6d8064..124041954 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -3,15 +3,19 @@ """ import tkinter as tk from tkinter import ttk +from tkinter import filedialog +from tkinter import messagebox from tk_tools import TK_ROOT from datetime import datetime +from io import BytesIO import time import os +import shutil from FakeZip import FakeZip, zip_names -from zipfile import ZipFile +from zipfile import ZipFile, ZIP_LZMA from tooltip import add_tooltip from property_parser import Property @@ -45,6 +49,11 @@ # The name of the current backup file 'backup_path': None, + + # The backup zip file + 'backup_zip': None, # type: ZipFile + # The currently-open file + 'unsaved_file': None, } # Variables associated with the heading text. @@ -62,11 +71,29 @@ ) - class P2C: """A PeTI map.""" - def __init__(self, path, zip_file): - """Initialise the map. + def __init__( + self, + path, + zip_file, + create_time, + mod_time, + title='', + desc='', + is_coop=False, + ): + self.path = path + self.zip_file = zip_file + self.create_time = create_time + self.mod_time = mod_time + self.title = title + self.desc = desc + self.is_coop = is_coop + + @classmethod + def from_file(cls, path, zip_file): + """Initialise from a file. path is the file path for the map inside the zip, without extension. zip_file is either a ZipFile or FakeZip object. @@ -75,24 +102,42 @@ def __init__(self, path, zip_file): props = Property.parse(file, path) props = props.find_key('portal2_puzzle', []) - self.path = path - self.zip_file = zip_file - self.title = props['title', None] - if self.title is None: - self.title = '<' + path.rsplit('/', 1)[-1] + '.p2c>' - self.desc = props['description', '...'] - self.is_coop = utils.conv_bool(props['coop', '0']) - self.create_time = Date(props['timestamp_created', '']) - self.mod_time = Date(props['timestamp_modified', '']) + title = props['title', None] + if title is None: + title = '<' + path.rsplit('/', 1)[-1] + '.p2c>' + + return cls( + path=path, + zip_file = zip_file, + title=title, + desc=props['description', '...'], + is_coop=utils.conv_bool(props['coop', '0']), + create_time=Date(props['timestamp_created', '']), + mod_time=Date(props['timestamp_modified', '']), + ) + + def copy(self): + """Copy this item.""" + return self.__class__( + self.path, + create_time=self.create_time, + zip_file=self.zip_file, + mod_time=self.mod_time, + is_coop=self.is_coop, + desc=self.desc, + title=self.title, + ) def make_item(self): """Make a corresponding CheckItem object.""" - return CheckItem( + chk = CheckItem( self.title, ('Coop' if self.is_coop else 'SP'), self.mod_time, hover_text=self.desc ) + chk.p2c = self + return chk class Date: @@ -161,7 +206,7 @@ def load_backup(zip_file): """Load in a backup file.""" maps = [] puzzles = [ - file[:-4] # Strip extension + file[:-4] # Strip extension for file in zip_names(zip_file) if file.endswith('.p2c') @@ -171,7 +216,7 @@ def load_backup(zip_file): reading_loader.set_length('READ', len(puzzles)) with reading_loader: for file in puzzles: - maps.append(P2C(file, zip_file)) + maps.append(P2C.from_file(file, zip_file)) reading_loader.step('READ') return maps @@ -194,6 +239,50 @@ def load_game(game: gameMan.Game): refresh_game_details() +def backup_maps(maps): + """Copy the given maps to the backup.""" + back_zip = BACKUPS['backup_zip'] # type: ZipFile + copy_loader.set_length('COPY', len(maps)) + with copy_loader: + for p2c in maps: + game_zip = p2c.zip_file # type: FakeZip + scr_path = p2c.path + '.jpg' + map_path = p2c.path + '.p2c' + if ( + map_path in zip_names(back_zip) or + scr_path in zip_names(back_zip) + ): + if not messagebox.askyesno( + title='Overwrite File?', + message='This filename is already in the backup.' + 'Do you wish to overwrite it? ' + '({})'.format(p2c.title), + parent=window, + icon=messagebox.QUESTION, + ): + copy_loader.step('COPY') + continue + + if scr_path in zip_names(game_zip): + with game_zip.open(scr_path, 'rb') as src: + back_zip.writestr( + scr_path, + src.read(), + compress_type=ZIP_LZMA, + ) + + with game_zip.open(map_path, 'rb') as src: + back_zip.writestr( + map_path, + src.read(), + compress_type=ZIP_LZMA, + ) + new_item = p2c.copy() + new_item.zip_file = back_zip + BACKUPS['back'].append(new_item) + refresh_back_details() + + def refresh_game_details(): """Remake the items in the game maps list.""" game = UI['game_details'] @@ -224,14 +313,80 @@ def show_window(): def ui_load_backup(): """Prompt and load in a backup file.""" - pass + file = filedialog.askopenfilename( + title='Load Backup', + filetypes=[('Backup zip', '.zip')], + ) + if not file: + return + + BACKUPS['backup_path'] = file + with open(file, 'rb') as f: + # Read the backup zip into memory! + BACKUPS['unsaved_file'] = unsaved = BytesIO(f.read()) + + BACKUPS['backup_zip'] = zip_file = ZipFile(unsaved, mode='w') + BACKUPS['back'] = load_backup(zip_file) + + BACKUPS['backup_name'] = os.path.basename(file) + backup_name.set(BACKUPS['backup_name']) + + print(BACKUPS['back']) + + refresh_back_details() def ui_new_backup(): """Create a new backup file.""" BACKUPS['back'].clear() BACKUPS['backup_name'] = None + BACKUPS['backup_path'] = None backup_name.set('Unsaved Backup') + BACKUPS['unsaved_file'] = unsaved = BytesIO() + BACKUPS['backup_zip'] = ZipFile( + unsaved, + mode='w', + compression=ZIP_LZMA, + ) + + +def ui_save_backup(): + """Save a backup.""" + if BACKUPS['backup_path'] is None: + # No backup path, prompt first + ui_save_backup_as() + return + + # Close the zipfile to write the contents properly + # That doesn't close the BytesIO object! + BACKUPS['backup_zip'].close() + + with open(BACKUPS['backup_path'], 'wb') as backup: + backup.write(BACKUPS['unsaved_file'].getvalue()) + + # Remake the zipfile object. + BACKUPS['backup_zip'] = ZipFile( + BACKUPS['unsaved_file'], + mode='w', + compression=ZIP_LZMA, + ) + + +def ui_save_backup_as(): + """Prompt for a name, and then save a backup.""" + path = filedialog.asksaveasfilename( + title='Save Backup As', + filetypes=[('Backup zip', '.zip')], + ) + if not path: + return + if not path.endswith('.zip'): + path += '.zip' + + BACKUPS['backup_path'] = path + BACKUPS['backup_name'] = os.path.basename(path) + backup_name.set(BACKUPS['backup_name']) + ui_save_backup() def ui_refresh_game(): @@ -240,6 +395,24 @@ def ui_refresh_game(): load_game(gameMan.selected_game) +def ui_backup_sel(): + """Backup selected maps.""" + backup_maps([ + item.p2c + for item in + UI['game_details'].items + if item.state + ]) + +def ui_backup_all(): + """Backup all maps.""" + backup_maps([ + item.p2c + for item in + UI['game_details'].items + ]) + + def init(): """Initialise all widgets in the given window.""" for cat, btn_text in [ @@ -313,6 +486,9 @@ def init(): UI['game_title']['textvariable'] = game_name UI['back_title']['textvariable'] = backup_name + UI['game_btn_all']['command'] = ui_backup_all + UI['game_btn_sel']['command'] = ui_backup_sel + UI['back_frame'].grid(row=1, column=0, sticky='NSEW') ttk.Separator(orient=tk.VERTICAL).grid( row=1, column=1, sticky='NS', padx=5, @@ -345,9 +521,9 @@ def init_application(): else: file_menu = menus['file'] = tk.Menu(bar) file_menu.add_command(label='New Backup', command=ui_new_backup) - file_menu.add_command(label='Open Backup') - file_menu.add_command(label='Save Backup') - file_menu.add_command(label='Save Backup As') + file_menu.add_command(label='Open Backup', command=ui_load_backup) + file_menu.add_command(label='Save Backup', command=ui_save_backup) + file_menu.add_command(label='Save Backup As', command=ui_save_backup_as) bar.add_cascade(menu=file_menu, label='File') From 8e97a3a22b5d258336fec469c629a428f6a64c5c Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 15:50:58 +1000 Subject: [PATCH 51/91] Restore functionality --- src/Backup.py | 183 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 144 insertions(+), 39 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index 124041954..c706ff3b3 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -47,6 +47,9 @@ 'game': [], 'back': [], + # The path for the game folder + 'game_path': None, + # The name of the current backup file 'backup_path': None, @@ -233,8 +236,10 @@ def load_game(game: gameMan.Game): continue abs_path = os.path.join(path, folder) if os.path.isdir(abs_path): - zip_file = FakeZip(abs_path) + BACKUPS['game_path'] = abs_path + BACKUPS['game_zip'] = zip_file = FakeZip(abs_path) maps = load_backup(zip_file) + BACKUPS['game'] = maps refresh_game_details() @@ -242,19 +247,109 @@ def load_game(game: gameMan.Game): def backup_maps(maps): """Copy the given maps to the backup.""" back_zip = BACKUPS['backup_zip'] # type: ZipFile + + # Allow removing old maps when we overwrite objects + map_dict = { + p2c.path: p2c + for p2c in + BACKUPS['back'] + } + + # You can't remove files from a zip, so we need to create a new one! + # Here we'll just add entries into BACKUPS['back']. + # Also check for overwriting + for p2c in maps: + scr_path = p2c.path + '.jpg' + map_path = p2c.path + '.p2c' + if ( + map_path in zip_names(back_zip) or + scr_path in zip_names(back_zip) + ): + if not messagebox.askyesno( + title='Overwrite File?', + message='This filename is already in the backup.' + 'Do you wish to overwrite it? ' + '({})'.format(p2c.title), + parent=window, + icon=messagebox.QUESTION, + ): + continue + new_item = p2c.copy() + map_dict[p2c.path] = new_item + + BACKUPS['back'] = list(map_dict.values()) + refresh_back_details() + + +def save_backup(): + """Save the backup file.""" + # We generate it from scratch, since that's the only way to remove + # files. + new_zip_data = BytesIO() + new_zip = ZipFile(new_zip_data, 'w', compression=ZIP_LZMA) + + maps = [ + item.p2c + for item in + UI['back_details'].items + ] + + copy_loader.set_length('COPY', len(maps)) + + with copy_loader: + for p2c in maps: + old_zip = p2c.zip_file + map_path = p2c.path + '.p2c' + scr_path = p2c.path + '.jpg' + if scr_path in zip_names(old_zip): + if isinstance(old_zip, FakeZip): + with old_zip.open(scr_path, 'rb') as f: + new_zip.writestr(scr_path, f.read()) + else: + with old_zip.open(scr_path, 'r') as f: + new_zip.writestr(scr_path, f.read()) + + with old_zip.open(map_path, 'r') as f: + new_zip.writestr(map_path, f.read()) + copy_loader.step('COPY') + + new_zip.close() # Finalize zip + + with open(BACKUPS['backup_path'], 'wb') as backup: + backup.write(new_zip_data.getvalue()) + BACKUPS['unsaved_file'] = new_zip_data + + # Remake the zipfile object, so it's open again. + BACKUPS['backup_zip'] = new_zip = ZipFile( + new_zip_data, + mode='w', + compression=ZIP_LZMA, + ) + + # Update the items, so they use this zip now. + for p2c in maps: + p2c.zip_file = new_zip + + +def restore_maps(maps): + """Copy the given maps to the game.""" + game_dir = BACKUPS['game_path'] + back_zip = BACKUPS['backup_zip'] + copy_loader.set_length('COPY', len(maps)) with copy_loader: for p2c in maps: - game_zip = p2c.zip_file # type: FakeZip scr_path = p2c.path + '.jpg' map_path = p2c.path + '.p2c' + abs_scr = os.path.join(game_dir, scr_path) + abs_map = os.path.join(game_dir, map_path) if ( - map_path in zip_names(back_zip) or - scr_path in zip_names(back_zip) + os.path.isfile(abs_scr) or + os.path.isfile(abs_map) ): if not messagebox.askyesno( title='Overwrite File?', - message='This filename is already in the backup.' + message='This map is already in the game directory.' 'Do you wish to overwrite it? ' '({})'.format(p2c.title), parent=window, @@ -263,24 +358,21 @@ def backup_maps(maps): copy_loader.step('COPY') continue - if scr_path in zip_names(game_zip): - with game_zip.open(scr_path, 'rb') as src: - back_zip.writestr( - scr_path, - src.read(), - compress_type=ZIP_LZMA, - ) - - with game_zip.open(map_path, 'rb') as src: - back_zip.writestr( - map_path, - src.read(), - compress_type=ZIP_LZMA, - ) + if scr_path in zip_names(back_zip): + with back_zip.open(scr_path, 'r') as src: + with open(abs_scr, 'wb') as dest: + shutil.copyfileobj(src, dest) + + with back_zip.open(map_path, 'r') as src: + with open(abs_map, 'wb') as dest: + shutil.copyfileobj(src, dest) + new_item = p2c.copy() - new_item.zip_file = back_zip - BACKUPS['back'].append(new_item) - refresh_back_details() + new_item.zip_file = FakeZip(game_dir) + BACKUPS['game'].append(new_item) + copy_loader.step('COPY') + + refresh_game_details() def refresh_game_details(): @@ -323,16 +415,19 @@ def ui_load_backup(): BACKUPS['backup_path'] = file with open(file, 'rb') as f: # Read the backup zip into memory! - BACKUPS['unsaved_file'] = unsaved = BytesIO(f.read()) + data = f.read() + BACKUPS['unsaved_file'] = unsaved = BytesIO(data) - BACKUPS['backup_zip'] = zip_file = ZipFile(unsaved, mode='w') + BACKUPS['backup_zip'] = zip_file = ZipFile( + unsaved, + mode='a', + compression=ZIP_LZMA, + ) BACKUPS['back'] = load_backup(zip_file) BACKUPS['backup_name'] = os.path.basename(file) backup_name.set(BACKUPS['backup_name']) - print(BACKUPS['back']) - refresh_back_details() @@ -357,19 +452,7 @@ def ui_save_backup(): ui_save_backup_as() return - # Close the zipfile to write the contents properly - # That doesn't close the BytesIO object! - BACKUPS['backup_zip'].close() - - with open(BACKUPS['backup_path'], 'wb') as backup: - backup.write(BACKUPS['unsaved_file'].getvalue()) - - # Remake the zipfile object. - BACKUPS['backup_zip'] = ZipFile( - BACKUPS['unsaved_file'], - mode='w', - compression=ZIP_LZMA, - ) + save_backup() def ui_save_backup_as(): @@ -404,6 +487,7 @@ def ui_backup_sel(): if item.state ]) + def ui_backup_all(): """Backup all maps.""" backup_maps([ @@ -412,6 +496,24 @@ def ui_backup_all(): UI['game_details'].items ]) +def ui_restore_sel(): + """Restore selected maps.""" + restore_maps([ + item.p2c + for item in + UI['back_details'].items + if item.state + ]) + + +def ui_restore_all(): + """Backup all maps.""" + restore_maps([ + item.p2c + for item in + UI['back_details'].items + ]) + def init(): """Initialise all widgets in the given window.""" @@ -489,6 +591,9 @@ def init(): UI['game_btn_all']['command'] = ui_backup_all UI['game_btn_sel']['command'] = ui_backup_sel + UI['back_btn_all']['command'] = ui_restore_all + UI['back_btn_sel']['command'] = ui_restore_sel + UI['back_frame'].grid(row=1, column=0, sticky='NSEW') ttk.Separator(orient=tk.VERTICAL).grid( row=1, column=1, sticky='NS', padx=5, From b2dffa63596d0ccaba3bd26fac4a5c922038301b Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 16:22:07 +1000 Subject: [PATCH 52/91] Add function to read binary files from a zip --- src/FakeZip.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/FakeZip.py b/src/FakeZip.py index 4a4f6efb6..d39e98bbb 100644 --- a/src/FakeZip.py +++ b/src/FakeZip.py @@ -110,4 +110,12 @@ def zip_names(zip): if hasattr(zip, 'names'): return zip.names() else: - return zip.namelist() \ No newline at end of file + return zip.namelist() + + +def zip_open_bin(zip, filename): + """Open in binary mode if a fake zip.""" + if isinstance(zip, FakeZip): + return zip.open(filename, 'rb') + else: + return zip.open(filename, 'r') \ No newline at end of file From 7c5458bbe214c635a0827028a7fffa101e144d92 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 16:22:35 +1000 Subject: [PATCH 53/91] Add top toolbar when embedding in the BEE2 --- src/Backup.py | 58 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index c706ff3b3..0e3a2093a 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -14,7 +14,7 @@ import os import shutil -from FakeZip import FakeZip, zip_names +from FakeZip import FakeZip, zip_names, zip_open_bin from zipfile import ZipFile, ZIP_LZMA from tooltip import add_tooltip @@ -302,12 +302,8 @@ def save_backup(): map_path = p2c.path + '.p2c' scr_path = p2c.path + '.jpg' if scr_path in zip_names(old_zip): - if isinstance(old_zip, FakeZip): - with old_zip.open(scr_path, 'rb') as f: - new_zip.writestr(scr_path, f.read()) - else: - with old_zip.open(scr_path, 'r') as f: - new_zip.writestr(scr_path, f.read()) + with zip_open_bin(old_zip, scr_path) as f: + new_zip.writestr(scr_path, f.read()) with old_zip.open(map_path, 'r') as f: new_zip.writestr(map_path, f.read()) @@ -334,11 +330,11 @@ def save_backup(): def restore_maps(maps): """Copy the given maps to the game.""" game_dir = BACKUPS['game_path'] - back_zip = BACKUPS['backup_zip'] copy_loader.set_length('COPY', len(maps)) with copy_loader: for p2c in maps: + back_zip = p2c.zip_file scr_path = p2c.path + '.jpg' map_path = p2c.path + '.p2c' abs_scr = os.path.join(game_dir, scr_path) @@ -359,11 +355,11 @@ def restore_maps(maps): continue if scr_path in zip_names(back_zip): - with back_zip.open(scr_path, 'r') as src: - with open(abs_scr, 'wb') as dest: - shutil.copyfileobj(src, dest) + with zip_open_bin(back_zip, scr_path) as src: + with open(abs_scr, 'wb') as dest: + shutil.copyfileobj(src, dest) - with back_zip.open(map_path, 'r') as src: + with zip_open_bin(back_zip, map_path) as src: with open(abs_map, 'wb') as dest: shutil.copyfileobj(src, dest) @@ -401,6 +397,8 @@ def show_window(): window.deiconify() window.lift() utils.center_win(window, TK_ROOT) + # Load our game data! + ui_refresh_game() def ui_load_backup(): @@ -496,6 +494,7 @@ def ui_backup_all(): UI['game_details'].items ]) + def ui_restore_sel(): """Restore selected maps.""" restore_maps([ @@ -675,7 +674,40 @@ def init_toplevel(): init() init_backup_settings() - ui_refresh_game() + # When embedded in the BEE2, use regular buttons and a dropdown! + toolbar_frame = ttk.Frame( + window + ) + ttk.Button( + toolbar_frame, + text='New Backup', + command=ui_new_backup, + width=14, + ).grid(row=0, column=0) + + ttk.Button( + toolbar_frame, + text='Open Backup', + command=ui_load_backup, + width=13, + ).grid(row=0, column=1) + + ttk.Button( + toolbar_frame, + text='Save Backup', + command=ui_save_backup, + width=11, + ).grid(row=0, column=2) + + ttk.Button( + toolbar_frame, + text='.. As', + command=ui_save_backup_as, + width=5, + ).grid(row=0, column=3) + + toolbar_frame.grid(row=0, column=0, columnspan=3, sticky='W') + ui_new_backup() From 5787dae089aac5aca13283396be6600376436be7 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 17:45:57 +1000 Subject: [PATCH 54/91] Add ttk spinbox class This is missing from the normal tkinter, but is in ttk itself. This one also forces values to be integers. --- src/tk_tools.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/tk_tools.py b/src/tk_tools.py index f66be025c..f47a8ec77 100644 --- a/src/tk_tools.py +++ b/src/tk_tools.py @@ -53,4 +53,48 @@ def __init__(self, master, **opt): # These two TK commands are used for all text operations, # so cancelling them stops anything from happening. self.insert = redir.register('insert', event_cancel) - self.delete = redir.register('delete', event_cancel) \ No newline at end of file + self.delete = redir.register('delete', event_cancel) + + +class ttk_Spinbox(ttk.Widget, tk.Spinbox): + """This is missing from ttk, but still exists.""" + def __init__(self, master, range=None, **kw): + """Initialise a spinbox. + Arguments: + range: The range buttons will run in + values: A list of values to use + wrap: Wether to loop at max/min + format: A specifier of the form ' %.f' + command: A command to run whenever the value changes + """ + if range is not None: + kw['from'] = range.start + kw['to'] = range.stop + kw['increment'] = range.step + if 'width' not in kw: + kw['width'] = len(str(range.stop)) + 1 + + self.old_val = kw.get('from', '0') + kw['validate'] = 'all' + kw['validatecommand'] = self.validate + + ttk.Widget.__init__(self, master, 'ttk::spinbox', kw) + + @property + def value(self): + """Get the value of the spinbox.""" + return self.tk.call(self._w, 'get') + + @value.setter + def value(self, value): + """Set the spinbox to a value.""" + self.tk.call(self._w, 'set', value) + + def validate(self): + """Values must be integers.""" + try: + self.old_val = int(self.value) + return True + except ValueError: + self.value = str(self.old_val) + return False From aaf8191df8fffc1cb931db1786f960d8fb6dc1a1 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 15 Nov 2015 17:51:34 +1000 Subject: [PATCH 55/91] Add backup settings --- src/Backup.py | 84 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index 0e3a2093a..00f06654c 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -646,19 +646,90 @@ def init_application(): def init_backup_settings(): """Initialise the auto-backup settings widget.""" + from BEE2_config import GEN_OPTS + check_var = tk.IntVar( + value=GEN_OPTS.get_bool('General', 'enable_auto_backup') + ) + count_value = GEN_OPTS.get_int('General', 'auto_backup_count', 1) + back_dir = tk.StringVar( + value=GEN_OPTS.get_val('Directories', 'backup_loc', 'backups/') + ) + + def check_callback(): + GEN_OPTS['General']['enable_auto_backup'] = utils.bool_as_int( + check_var.get() + ) + + def count_callback(): + GEN_OPTS['General']['enable_auto_backup'] = str(count.value) + + dir_browser = filedialog.Directory(window, initialdir=back_dir.get()) + + def browse_folder(): + directory = dir_browser.show() + print(directory) + if directory: + GEN_OPTS['Directories']['backup_loc'] = directory + back_dir.set( + ('...' + directory[-20:]) + if len(directory) > 23 else + directory + ) + UI['auto_frame'] = frame = ttk.LabelFrame( window, ) UI['auto_enable'] = enable_check = ttk.Checkbutton( frame, text='Automatic Backup After Export', + variable=check_var, + command=check_callback, ) + frame['labelwidget'] = enable_check frame.grid(row=2, column=0, columnspan=3) - UI['auto_dir'] = tk_tools.ReadOnlyEntry(frame) + dir_frame = ttk.Frame( + frame, + ) + dir_frame.grid(row=0, column=0) + + ttk.Label( + dir_frame, + text='Directory', + ).grid(row=0, column=0, columnspan=2) + + UI['auto_dir'] = tk_tools.ReadOnlyEntry( + dir_frame, + textvariable=back_dir, + width=24, + ) + UI['auto_dir'].grid(row=1, column=0) + + browse_button = ttk.Button( + dir_frame, + text="...", + width=1.5, + command=browse_folder, + ) + browse_button.grid(row=1, column=1, sticky='W') + + count_frame = ttk.Frame( + frame, + ) + count_frame.grid(row=0, column=1) + ttk.Label( + count_frame, + text='Keep (Per Game):' + ).grid(row=0, column=0) - UI['auto_dir'].grid(row=0, column=0) + count = tk_tools.ttk_Spinbox( + count_frame, + range=range(1, 50), + command=count_callback, + ) + count.grid(row=1, column=0) + count.value = count_value def init_toplevel(): @@ -668,15 +739,20 @@ def init_toplevel(): window.transient(TK_ROOT) window.withdraw() + def quit_command(): + from BEE2_config import GEN_OPTS + window.withdraw() + GEN_OPTS.save_check() + # Don't destroy window when quit! - window.protocol("WM_DELETE_WINDOW", window.withdraw) + window.protocol("WM_DELETE_WINDOW", quit_command) init() init_backup_settings() # When embedded in the BEE2, use regular buttons and a dropdown! toolbar_frame = ttk.Frame( - window + window, ) ttk.Button( toolbar_frame, From 22de817b700642ed1c15b664728bda4e02aafda3 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 09:37:25 +1000 Subject: [PATCH 56/91] Handle invalid boolean values --- src/BEE2_config.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/BEE2_config.py b/src/BEE2_config.py index c7f161570..eaf7029ea 100644 --- a/src/BEE2_config.py +++ b/src/BEE2_config.py @@ -1,6 +1,6 @@ import os.path -from configparser import ConfigParser +from configparser import ConfigParser, NoOptionError class ConfigFile(ConfigParser): @@ -79,9 +79,10 @@ def getboolean(self, section, value, default=False) -> bool: """ if section not in self: self[section] = {} - if value in self[section]: + try: return super().getboolean(section, value) - else: + except (ValueError, NoOptionError): + # Invalid boolean, or not found self.has_changed = True self[section][value] = str(int(default)) return default @@ -95,9 +96,9 @@ def getint(self, section, value, default=0) -> int: """ if section not in self: self[section] = {} - if value in self[section]: + try: return super().getint(section, value) - else: + except (ValueError, NoOptionError): self.has_changed = True self[section][value] = str(int(default)) return default From 4bc70fff0d02f62ba593a20ddcabf55f9cd84e31 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 09:41:35 +1000 Subject: [PATCH 57/91] Fix comparison This shouldn't use 'is' - we only care if the string representation changes. --- src/BEE2_config.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/BEE2_config.py b/src/BEE2_config.py index eaf7029ea..3635dcc63 100644 --- a/src/BEE2_config.py +++ b/src/BEE2_config.py @@ -4,6 +4,13 @@ class ConfigFile(ConfigParser): + """A version of ConfigParser which can easily save itself. + + The config will track whether any values change, and only resave + if modified. + get_val, get_bool, and get_int are modified to return defaults instead + of erroring. + """ def __init__(self, filename, root='../config', auto_load=True): """Initialise the config file. @@ -115,9 +122,10 @@ def remove_section(self, section): def set(self, section, option, value=None): orig_val = self.get(section, option, fallback=None) - if orig_val is None or orig_val is not value: + value = str(value) + if orig_val is None or orig_val != value: self.has_changed = True - super().set(section, option, str(value)) + super().set(section, option, value) add_section.__doc__ = ConfigParser.add_section.__doc__ remove_section.__doc__ = ConfigParser.remove_section.__doc__ From 3f735448a218c1a6f756f5926e00cc5d1991d727 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 09:56:28 +1000 Subject: [PATCH 58/91] Pull directory browser widget into a generic class This way it can be reused elsewhere --- src/Backup.py | 35 +++++--------------- src/tk_tools.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/src/Backup.py b/src/Backup.py index 00f06654c..b881f1e7c 100644 --- a/src/Backup.py +++ b/src/Backup.py @@ -651,9 +651,7 @@ def init_backup_settings(): value=GEN_OPTS.get_bool('General', 'enable_auto_backup') ) count_value = GEN_OPTS.get_int('General', 'auto_backup_count', 1) - back_dir = tk.StringVar( - value=GEN_OPTS.get_val('Directories', 'backup_loc', 'backups/') - ) + back_dir = GEN_OPTS.get_val('Directories', 'backup_loc', 'backups/') def check_callback(): GEN_OPTS['General']['enable_auto_backup'] = utils.bool_as_int( @@ -663,18 +661,8 @@ def check_callback(): def count_callback(): GEN_OPTS['General']['enable_auto_backup'] = str(count.value) - dir_browser = filedialog.Directory(window, initialdir=back_dir.get()) - - def browse_folder(): - directory = dir_browser.show() - print(directory) - if directory: - GEN_OPTS['Directories']['backup_loc'] = directory - back_dir.set( - ('...' + directory[-20:]) - if len(directory) > 23 else - directory - ) + def directory_callback(path): + GEN_OPTS['Directories']['backup_loc'] = path UI['auto_frame'] = frame = ttk.LabelFrame( window, @@ -697,23 +685,16 @@ def browse_folder(): ttk.Label( dir_frame, text='Directory', - ).grid(row=0, column=0, columnspan=2) + ).grid(row=0, column=0) - UI['auto_dir'] = tk_tools.ReadOnlyEntry( + UI['auto_dir'] = tk_tools.FileField( dir_frame, - textvariable=back_dir, - width=24, + loc=back_dir, + is_dir=True, + callback=directory_callback, ) UI['auto_dir'].grid(row=1, column=0) - browse_button = ttk.Button( - dir_frame, - text="...", - width=1.5, - command=browse_folder, - ) - browse_button.grid(row=1, column=1, sticky='W') - count_frame = ttk.Frame( frame, ) diff --git a/src/tk_tools.py b/src/tk_tools.py index f47a8ec77..6d97b7243 100644 --- a/src/tk_tools.py +++ b/src/tk_tools.py @@ -3,8 +3,11 @@ """ from tkinter import ttk +from tkinter import filedialog import tkinter as tk +import os.path + from idlelib.WidgetRedirector import WidgetRedirector import utils @@ -98,3 +101,86 @@ def validate(self): except ValueError: self.value = str(self.old_val) return False + + +class FileField(ttk.Frame): + """A text box which allows searching for a file or directory. + """ + def __init__(self, master, is_dir=False, loc='', width=24, callback=None): + """Initialise the field. + + - Set is_dir to true to look for directories, instead of files. + - width sets the number of characters to display. + - loc is the initial value of the field. + - callback is a function to be called with the new path whenever it + changes. + """ + super(FileField, self).__init__(master) + + self._location = loc + self.is_dir = is_dir + self.width = width + + self._text_var = tk.StringVar(master=self, value=self._truncate(loc)) + if is_dir: + self.browser = filedialog.Directory( + self, + initialdir=loc, + ) + else: + self.browser = filedialog.SaveAs( + self, + initialdir=loc, + ) + + if callback is not None: + self.callback = callback + + self.textbox = ReadOnlyEntry( + self, + textvariable=self._text_var, + width=width, + cursor=utils.CURSORS['regular'], + ) + self.textbox.grid(row=0, column=0) + utils.bind_leftclick(self.textbox, self.browse) + + self.browse_btn = ttk.Button( + self, + text="...", + width=1.5, + command=self.browse, + ) + self.browse_btn.grid(row=0, column=1) + + def browse(self, event=None): + """Browse for a file.""" + path = self.browser.show() + if path: + self.value = path + + def callback(self, path): + """Callback function, called whenever the value changes.""" + pass + + @property + def value(self): + """Get the current path.""" + return self._location + + @value.setter + def value(self, path): + """Set the current path. This calls the callback function.""" + self.callback(path) + self._location = path + self._text_var.set(self._truncate(path)) + + def _truncate(self, path): + """Truncate the path to the end portion.""" + if not self.is_dir: + path = os.path.basename(path) + + if len(path) > self.width - 4: + return '...' + path[-(self.width - 1):] + + return path From 8ff5a2e94e0a98edac96a9f07a1a5f2a77dcb2c2 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 10:02:53 +1000 Subject: [PATCH 59/91] Fix backup module filename --- src/Backup.py | 784 -------------------------------------------------- 1 file changed, 784 deletions(-) delete mode 100644 src/Backup.py diff --git a/src/Backup.py b/src/Backup.py deleted file mode 100644 index b881f1e7c..000000000 --- a/src/Backup.py +++ /dev/null @@ -1,784 +0,0 @@ -"""Backup and restore P2C maps. - -""" -import tkinter as tk -from tkinter import ttk -from tkinter import filedialog -from tkinter import messagebox - -from tk_tools import TK_ROOT - -from datetime import datetime -from io import BytesIO -import time -import os -import shutil - -from FakeZip import FakeZip, zip_names, zip_open_bin -from zipfile import ZipFile, ZIP_LZMA - -from tooltip import add_tooltip -from property_parser import Property -from CheckDetails import CheckDetails, Item as CheckItem -from loadScreen import LoadScreen -import img -import utils -import tk_tools -import gameMan - - -window = None # type: tk.Toplevel - -UI = {} - -menus = {} # For standalone application, generate menu bars - -HEADERS = ['Name', 'Mode', 'Date'] - -# The game subfolder where puzzles are located -PUZZLE_FOLDERS = { - utils.STEAM_IDS['PORTAL2']: 'portal2', - utils.STEAM_IDS['APTAG']: 'aperturetag', - utils.STEAM_IDS['TWTM']: 'TWTM', -} - -# The currently-loaded backup files. -BACKUPS = { - 'game': [], - 'back': [], - - # The path for the game folder - 'game_path': None, - - # The name of the current backup file - 'backup_path': None, - - # The backup zip file - 'backup_zip': None, # type: ZipFile - # The currently-open file - 'unsaved_file': None, -} - -# Variables associated with the heading text. -backup_name = tk.StringVar() -game_name = tk.StringVar() - -copy_loader = LoadScreen( - ('COPY', ''), - title_text='Copying maps', -) - -reading_loader = LoadScreen( - ('READ', ''), - title_text='Loading maps', -) - - -class P2C: - """A PeTI map.""" - def __init__( - self, - path, - zip_file, - create_time, - mod_time, - title='', - desc='', - is_coop=False, - ): - self.path = path - self.zip_file = zip_file - self.create_time = create_time - self.mod_time = mod_time - self.title = title - self.desc = desc - self.is_coop = is_coop - - @classmethod - def from_file(cls, path, zip_file): - """Initialise from a file. - - path is the file path for the map inside the zip, without extension. - zip_file is either a ZipFile or FakeZip object. - """ - with zip_file.open(path + '.p2c') as file: - props = Property.parse(file, path) - props = props.find_key('portal2_puzzle', []) - - title = props['title', None] - if title is None: - title = '<' + path.rsplit('/', 1)[-1] + '.p2c>' - - return cls( - path=path, - zip_file = zip_file, - title=title, - desc=props['description', '...'], - is_coop=utils.conv_bool(props['coop', '0']), - create_time=Date(props['timestamp_created', '']), - mod_time=Date(props['timestamp_modified', '']), - ) - - def copy(self): - """Copy this item.""" - return self.__class__( - self.path, - create_time=self.create_time, - zip_file=self.zip_file, - mod_time=self.mod_time, - is_coop=self.is_coop, - desc=self.desc, - title=self.title, - ) - - def make_item(self): - """Make a corresponding CheckItem object.""" - chk = CheckItem( - self.title, - ('Coop' if self.is_coop else 'SP'), - self.mod_time, - hover_text=self.desc - ) - chk.p2c = self - return chk - - -class Date: - """A version of datetime with an invalid value, and read from hex. - """ - def __init__(self, hex_time): - """Convert the time format in P2C files into a useable value.""" - try: - val = int(hex_time, 16) - except ValueError: - self.date = None - else: - self.date = datetime.fromtimestamp(val) - - def __str__(self): - """Return value for display.""" - if self.date is None: - return '???' - else: - return time.strftime( - '%d %b %Y, %I:%M%p', - self.date.timetuple(), - ) - - # No date = always earlier - def __lt__(self, other): - if self.date is None: - return True - elif other.date is None: - return False - else: - return self.date < other.date - - def __gt__(self, other): - if self.date is None: - return False - elif other.date is None: - return True - else: - return self.date > other.date - - def __le__(self, other): - if self.date is None: - return other.date is None - else: - return self.date <= other.date - - def __ge__(self, other): - if self.date is None: - return other.date is None - else: - return self.date >= other.date - - def __eq__(self, other): - return self.date == other.date - - def __ne__(self, other): - return self.date != other.date - - -# Note: All the backup functions use zip files, but also work on FakeZip -# directories. - - -def load_backup(zip_file): - """Load in a backup file.""" - maps = [] - puzzles = [ - file[:-4] # Strip extension - for file in - zip_names(zip_file) - if file.endswith('.p2c') - ] - # Each P2C init requires reading in the properties file, so this may take - # some time. Use a loading screen. - reading_loader.set_length('READ', len(puzzles)) - with reading_loader: - for file in puzzles: - maps.append(P2C.from_file(file, zip_file)) - reading_loader.step('READ') - - return maps - - -def load_game(game: gameMan.Game): - """Callback for gameMan, load in files for a game.""" - game_name.set(game.name) - - puzzle_folder = PUZZLE_FOLDERS.get(str(game.steamID), 'portal2') - path = game.abs_path(puzzle_folder + '/puzzles/') - for folder in os.listdir(path): - if not folder.isdigit(): - continue - abs_path = os.path.join(path, folder) - if os.path.isdir(abs_path): - BACKUPS['game_path'] = abs_path - BACKUPS['game_zip'] = zip_file = FakeZip(abs_path) - maps = load_backup(zip_file) - - BACKUPS['game'] = maps - refresh_game_details() - - -def backup_maps(maps): - """Copy the given maps to the backup.""" - back_zip = BACKUPS['backup_zip'] # type: ZipFile - - # Allow removing old maps when we overwrite objects - map_dict = { - p2c.path: p2c - for p2c in - BACKUPS['back'] - } - - # You can't remove files from a zip, so we need to create a new one! - # Here we'll just add entries into BACKUPS['back']. - # Also check for overwriting - for p2c in maps: - scr_path = p2c.path + '.jpg' - map_path = p2c.path + '.p2c' - if ( - map_path in zip_names(back_zip) or - scr_path in zip_names(back_zip) - ): - if not messagebox.askyesno( - title='Overwrite File?', - message='This filename is already in the backup.' - 'Do you wish to overwrite it? ' - '({})'.format(p2c.title), - parent=window, - icon=messagebox.QUESTION, - ): - continue - new_item = p2c.copy() - map_dict[p2c.path] = new_item - - BACKUPS['back'] = list(map_dict.values()) - refresh_back_details() - - -def save_backup(): - """Save the backup file.""" - # We generate it from scratch, since that's the only way to remove - # files. - new_zip_data = BytesIO() - new_zip = ZipFile(new_zip_data, 'w', compression=ZIP_LZMA) - - maps = [ - item.p2c - for item in - UI['back_details'].items - ] - - copy_loader.set_length('COPY', len(maps)) - - with copy_loader: - for p2c in maps: - old_zip = p2c.zip_file - map_path = p2c.path + '.p2c' - scr_path = p2c.path + '.jpg' - if scr_path in zip_names(old_zip): - with zip_open_bin(old_zip, scr_path) as f: - new_zip.writestr(scr_path, f.read()) - - with old_zip.open(map_path, 'r') as f: - new_zip.writestr(map_path, f.read()) - copy_loader.step('COPY') - - new_zip.close() # Finalize zip - - with open(BACKUPS['backup_path'], 'wb') as backup: - backup.write(new_zip_data.getvalue()) - BACKUPS['unsaved_file'] = new_zip_data - - # Remake the zipfile object, so it's open again. - BACKUPS['backup_zip'] = new_zip = ZipFile( - new_zip_data, - mode='w', - compression=ZIP_LZMA, - ) - - # Update the items, so they use this zip now. - for p2c in maps: - p2c.zip_file = new_zip - - -def restore_maps(maps): - """Copy the given maps to the game.""" - game_dir = BACKUPS['game_path'] - - copy_loader.set_length('COPY', len(maps)) - with copy_loader: - for p2c in maps: - back_zip = p2c.zip_file - scr_path = p2c.path + '.jpg' - map_path = p2c.path + '.p2c' - abs_scr = os.path.join(game_dir, scr_path) - abs_map = os.path.join(game_dir, map_path) - if ( - os.path.isfile(abs_scr) or - os.path.isfile(abs_map) - ): - if not messagebox.askyesno( - title='Overwrite File?', - message='This map is already in the game directory.' - 'Do you wish to overwrite it? ' - '({})'.format(p2c.title), - parent=window, - icon=messagebox.QUESTION, - ): - copy_loader.step('COPY') - continue - - if scr_path in zip_names(back_zip): - with zip_open_bin(back_zip, scr_path) as src: - with open(abs_scr, 'wb') as dest: - shutil.copyfileobj(src, dest) - - with zip_open_bin(back_zip, map_path) as src: - with open(abs_map, 'wb') as dest: - shutil.copyfileobj(src, dest) - - new_item = p2c.copy() - new_item.zip_file = FakeZip(game_dir) - BACKUPS['game'].append(new_item) - copy_loader.step('COPY') - - refresh_game_details() - - -def refresh_game_details(): - """Remake the items in the game maps list.""" - game = UI['game_details'] - game.remove_all() - game.add_items(*( - peti_map.make_item() - for peti_map in - BACKUPS['game'] - )) - - -def refresh_back_details(): - """Remake the items in the backup list.""" - backup = UI['back_details'] - backup.remove_all() - backup.add_items(*( - peti_map.make_item() - for peti_map in - BACKUPS['back'] - )) - - -def show_window(): - window.deiconify() - window.lift() - utils.center_win(window, TK_ROOT) - # Load our game data! - ui_refresh_game() - - -def ui_load_backup(): - """Prompt and load in a backup file.""" - file = filedialog.askopenfilename( - title='Load Backup', - filetypes=[('Backup zip', '.zip')], - ) - if not file: - return - - BACKUPS['backup_path'] = file - with open(file, 'rb') as f: - # Read the backup zip into memory! - data = f.read() - BACKUPS['unsaved_file'] = unsaved = BytesIO(data) - - BACKUPS['backup_zip'] = zip_file = ZipFile( - unsaved, - mode='a', - compression=ZIP_LZMA, - ) - BACKUPS['back'] = load_backup(zip_file) - - BACKUPS['backup_name'] = os.path.basename(file) - backup_name.set(BACKUPS['backup_name']) - - refresh_back_details() - - -def ui_new_backup(): - """Create a new backup file.""" - BACKUPS['back'].clear() - BACKUPS['backup_name'] = None - BACKUPS['backup_path'] = None - backup_name.set('Unsaved Backup') - BACKUPS['unsaved_file'] = unsaved = BytesIO() - BACKUPS['backup_zip'] = ZipFile( - unsaved, - mode='w', - compression=ZIP_LZMA, - ) - - -def ui_save_backup(): - """Save a backup.""" - if BACKUPS['backup_path'] is None: - # No backup path, prompt first - ui_save_backup_as() - return - - save_backup() - - -def ui_save_backup_as(): - """Prompt for a name, and then save a backup.""" - path = filedialog.asksaveasfilename( - title='Save Backup As', - filetypes=[('Backup zip', '.zip')], - ) - if not path: - return - if not path.endswith('.zip'): - path += '.zip' - - BACKUPS['backup_path'] = path - BACKUPS['backup_name'] = os.path.basename(path) - backup_name.set(BACKUPS['backup_name']) - ui_save_backup() - - -def ui_refresh_game(): - """Reload the game maps list.""" - if gameMan.selected_game is not None: - load_game(gameMan.selected_game) - - -def ui_backup_sel(): - """Backup selected maps.""" - backup_maps([ - item.p2c - for item in - UI['game_details'].items - if item.state - ]) - - -def ui_backup_all(): - """Backup all maps.""" - backup_maps([ - item.p2c - for item in - UI['game_details'].items - ]) - - -def ui_restore_sel(): - """Restore selected maps.""" - restore_maps([ - item.p2c - for item in - UI['back_details'].items - if item.state - ]) - - -def ui_restore_all(): - """Backup all maps.""" - restore_maps([ - item.p2c - for item in - UI['back_details'].items - ]) - - -def init(): - """Initialise all widgets in the given window.""" - for cat, btn_text in [ - ('back_', 'Restore:'), - ('game_', 'Backup:'), - ]: - UI[cat + 'frame'] = frame = ttk.Frame( - window, - ) - UI[cat + 'title_frame'] = title_frame = ttk.Frame( - frame, - ) - title_frame.grid(row=0, column=0, sticky='EW') - UI[cat + 'title'] = ttk.Label( - title_frame, - font='TkHeadingFont', - ) - UI[cat + 'title'].grid(row=0, column=0) - title_frame.rowconfigure(0, weight=1) - title_frame.columnconfigure(0, weight=1) - - UI[cat + 'details'] = CheckDetails( - frame, - headers=HEADERS, - ) - UI[cat + 'details'].grid(row=1, column=0, sticky='NSEW') - frame.rowconfigure(1, weight=1) - frame.columnconfigure(0, weight=1) - - button_frame = ttk.Frame( - frame, - ) - button_frame.grid(column=0, row=2) - ttk.Label(button_frame, text=btn_text).grid(row=0, column=0) - UI[cat + 'btn_all'] = ttk.Button( - button_frame, - text='All', - width=3, - ) - UI[cat + 'btn_sel'] = ttk.Button( - button_frame, - text='Checked', - width=8, - ) - UI[cat + 'btn_all'].grid(row=0, column=1) - UI[cat + 'btn_sel'].grid(row=0, column=2) - - UI[cat + 'btn_del'] = ttk.Button( - button_frame, - text='Delete Checked', - width=14, - ) - UI[cat + 'btn_del'].grid(row=1, column=0, columnspan=3) - - utils.add_mousewheel( - UI[cat + 'details'].wid_canvas, - UI[cat + 'frame'], - ) - - UI['game_refresh'] = ttk.Button( - UI['game_title_frame'], - image=img.png('icons/tool_sub'), - command=ui_refresh_game, - ) - UI['game_refresh'].grid(row=0, column=1, sticky='E') - add_tooltip( - UI['game_refresh'], - "Reload the map list.", - ) - - UI['game_title']['textvariable'] = game_name - UI['back_title']['textvariable'] = backup_name - - UI['game_btn_all']['command'] = ui_backup_all - UI['game_btn_sel']['command'] = ui_backup_sel - - UI['back_btn_all']['command'] = ui_restore_all - UI['back_btn_sel']['command'] = ui_restore_sel - - UI['back_frame'].grid(row=1, column=0, sticky='NSEW') - ttk.Separator(orient=tk.VERTICAL).grid( - row=1, column=1, sticky='NS', padx=5, - ) - UI['game_frame'].grid(row=1, column=2, sticky='NSEW') - - window.rowconfigure(1, weight=1) - window.columnconfigure(0, weight=1) - window.columnconfigure(2, weight=1) - - -def init_application(): - """Initialise the standalone application.""" - global window - window = TK_ROOT - init() - - UI['bar'] = bar = tk.Menu(TK_ROOT) - window.option_add('*tearOff', False) - - gameMan.load() - ui_new_backup() - - # UI.py isn't present, so we use this callback - gameMan.setgame_callback = load_game - - if utils.MAC: - # Name is used to make this the special 'BEE2' menu item - file_menu = menus['file'] = tk.Menu(bar, name='apple') - else: - file_menu = menus['file'] = tk.Menu(bar) - file_menu.add_command(label='New Backup', command=ui_new_backup) - file_menu.add_command(label='Open Backup', command=ui_load_backup) - file_menu.add_command(label='Save Backup', command=ui_save_backup) - file_menu.add_command(label='Save Backup As', command=ui_save_backup_as) - - bar.add_cascade(menu=file_menu, label='File') - - game_menu = menus['game'] = tk.Menu(bar) - - game_menu.add_command(label='Add Game', command=gameMan.add_game) - game_menu.add_command(label='Remove Game', command=gameMan.remove_game) - game_menu.add_separator() - - bar.add_cascade(menu=game_menu, label='Game') - window['menu'] = bar - - gameMan.add_menu_opts(game_menu) - gameMan.game_menu = game_menu - - -def init_backup_settings(): - """Initialise the auto-backup settings widget.""" - from BEE2_config import GEN_OPTS - check_var = tk.IntVar( - value=GEN_OPTS.get_bool('General', 'enable_auto_backup') - ) - count_value = GEN_OPTS.get_int('General', 'auto_backup_count', 1) - back_dir = GEN_OPTS.get_val('Directories', 'backup_loc', 'backups/') - - def check_callback(): - GEN_OPTS['General']['enable_auto_backup'] = utils.bool_as_int( - check_var.get() - ) - - def count_callback(): - GEN_OPTS['General']['enable_auto_backup'] = str(count.value) - - def directory_callback(path): - GEN_OPTS['Directories']['backup_loc'] = path - - UI['auto_frame'] = frame = ttk.LabelFrame( - window, - ) - UI['auto_enable'] = enable_check = ttk.Checkbutton( - frame, - text='Automatic Backup After Export', - variable=check_var, - command=check_callback, - ) - - frame['labelwidget'] = enable_check - frame.grid(row=2, column=0, columnspan=3) - - dir_frame = ttk.Frame( - frame, - ) - dir_frame.grid(row=0, column=0) - - ttk.Label( - dir_frame, - text='Directory', - ).grid(row=0, column=0) - - UI['auto_dir'] = tk_tools.FileField( - dir_frame, - loc=back_dir, - is_dir=True, - callback=directory_callback, - ) - UI['auto_dir'].grid(row=1, column=0) - - count_frame = ttk.Frame( - frame, - ) - count_frame.grid(row=0, column=1) - ttk.Label( - count_frame, - text='Keep (Per Game):' - ).grid(row=0, column=0) - - count = tk_tools.ttk_Spinbox( - count_frame, - range=range(1, 50), - command=count_callback, - ) - count.grid(row=1, column=0) - count.value = count_value - - -def init_toplevel(): - """Initialise the window as part of the BEE2.""" - global window - window = tk.Toplevel(TK_ROOT) - window.transient(TK_ROOT) - window.withdraw() - - def quit_command(): - from BEE2_config import GEN_OPTS - window.withdraw() - GEN_OPTS.save_check() - - # Don't destroy window when quit! - window.protocol("WM_DELETE_WINDOW", quit_command) - - init() - init_backup_settings() - - # When embedded in the BEE2, use regular buttons and a dropdown! - toolbar_frame = ttk.Frame( - window, - ) - ttk.Button( - toolbar_frame, - text='New Backup', - command=ui_new_backup, - width=14, - ).grid(row=0, column=0) - - ttk.Button( - toolbar_frame, - text='Open Backup', - command=ui_load_backup, - width=13, - ).grid(row=0, column=1) - - ttk.Button( - toolbar_frame, - text='Save Backup', - command=ui_save_backup, - width=11, - ).grid(row=0, column=2) - - ttk.Button( - toolbar_frame, - text='.. As', - command=ui_save_backup_as, - width=5, - ).grid(row=0, column=3) - - toolbar_frame.grid(row=0, column=0, columnspan=3, sticky='W') - - ui_new_backup() - - -if __name__ == '__main__': - # Run this standalone. - init_application() - - TK_ROOT.deiconify() - - def fix_details(): - # It takes a while before the detail headers update positions, - # so delay a refresh call. - TK_ROOT.update_idletasks() - UI['game_details'].refresh() - TK_ROOT.after(500, fix_details) - - TK_ROOT.mainloop() From 0ee532a68b6aab2ecf91c7544f2aa5699ba26aaa Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 10:03:46 +1000 Subject: [PATCH 60/91] mend --- src/backup.py | 784 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 src/backup.py diff --git a/src/backup.py b/src/backup.py new file mode 100644 index 000000000..b881f1e7c --- /dev/null +++ b/src/backup.py @@ -0,0 +1,784 @@ +"""Backup and restore P2C maps. + +""" +import tkinter as tk +from tkinter import ttk +from tkinter import filedialog +from tkinter import messagebox + +from tk_tools import TK_ROOT + +from datetime import datetime +from io import BytesIO +import time +import os +import shutil + +from FakeZip import FakeZip, zip_names, zip_open_bin +from zipfile import ZipFile, ZIP_LZMA + +from tooltip import add_tooltip +from property_parser import Property +from CheckDetails import CheckDetails, Item as CheckItem +from loadScreen import LoadScreen +import img +import utils +import tk_tools +import gameMan + + +window = None # type: tk.Toplevel + +UI = {} + +menus = {} # For standalone application, generate menu bars + +HEADERS = ['Name', 'Mode', 'Date'] + +# The game subfolder where puzzles are located +PUZZLE_FOLDERS = { + utils.STEAM_IDS['PORTAL2']: 'portal2', + utils.STEAM_IDS['APTAG']: 'aperturetag', + utils.STEAM_IDS['TWTM']: 'TWTM', +} + +# The currently-loaded backup files. +BACKUPS = { + 'game': [], + 'back': [], + + # The path for the game folder + 'game_path': None, + + # The name of the current backup file + 'backup_path': None, + + # The backup zip file + 'backup_zip': None, # type: ZipFile + # The currently-open file + 'unsaved_file': None, +} + +# Variables associated with the heading text. +backup_name = tk.StringVar() +game_name = tk.StringVar() + +copy_loader = LoadScreen( + ('COPY', ''), + title_text='Copying maps', +) + +reading_loader = LoadScreen( + ('READ', ''), + title_text='Loading maps', +) + + +class P2C: + """A PeTI map.""" + def __init__( + self, + path, + zip_file, + create_time, + mod_time, + title='', + desc='', + is_coop=False, + ): + self.path = path + self.zip_file = zip_file + self.create_time = create_time + self.mod_time = mod_time + self.title = title + self.desc = desc + self.is_coop = is_coop + + @classmethod + def from_file(cls, path, zip_file): + """Initialise from a file. + + path is the file path for the map inside the zip, without extension. + zip_file is either a ZipFile or FakeZip object. + """ + with zip_file.open(path + '.p2c') as file: + props = Property.parse(file, path) + props = props.find_key('portal2_puzzle', []) + + title = props['title', None] + if title is None: + title = '<' + path.rsplit('/', 1)[-1] + '.p2c>' + + return cls( + path=path, + zip_file = zip_file, + title=title, + desc=props['description', '...'], + is_coop=utils.conv_bool(props['coop', '0']), + create_time=Date(props['timestamp_created', '']), + mod_time=Date(props['timestamp_modified', '']), + ) + + def copy(self): + """Copy this item.""" + return self.__class__( + self.path, + create_time=self.create_time, + zip_file=self.zip_file, + mod_time=self.mod_time, + is_coop=self.is_coop, + desc=self.desc, + title=self.title, + ) + + def make_item(self): + """Make a corresponding CheckItem object.""" + chk = CheckItem( + self.title, + ('Coop' if self.is_coop else 'SP'), + self.mod_time, + hover_text=self.desc + ) + chk.p2c = self + return chk + + +class Date: + """A version of datetime with an invalid value, and read from hex. + """ + def __init__(self, hex_time): + """Convert the time format in P2C files into a useable value.""" + try: + val = int(hex_time, 16) + except ValueError: + self.date = None + else: + self.date = datetime.fromtimestamp(val) + + def __str__(self): + """Return value for display.""" + if self.date is None: + return '???' + else: + return time.strftime( + '%d %b %Y, %I:%M%p', + self.date.timetuple(), + ) + + # No date = always earlier + def __lt__(self, other): + if self.date is None: + return True + elif other.date is None: + return False + else: + return self.date < other.date + + def __gt__(self, other): + if self.date is None: + return False + elif other.date is None: + return True + else: + return self.date > other.date + + def __le__(self, other): + if self.date is None: + return other.date is None + else: + return self.date <= other.date + + def __ge__(self, other): + if self.date is None: + return other.date is None + else: + return self.date >= other.date + + def __eq__(self, other): + return self.date == other.date + + def __ne__(self, other): + return self.date != other.date + + +# Note: All the backup functions use zip files, but also work on FakeZip +# directories. + + +def load_backup(zip_file): + """Load in a backup file.""" + maps = [] + puzzles = [ + file[:-4] # Strip extension + for file in + zip_names(zip_file) + if file.endswith('.p2c') + ] + # Each P2C init requires reading in the properties file, so this may take + # some time. Use a loading screen. + reading_loader.set_length('READ', len(puzzles)) + with reading_loader: + for file in puzzles: + maps.append(P2C.from_file(file, zip_file)) + reading_loader.step('READ') + + return maps + + +def load_game(game: gameMan.Game): + """Callback for gameMan, load in files for a game.""" + game_name.set(game.name) + + puzzle_folder = PUZZLE_FOLDERS.get(str(game.steamID), 'portal2') + path = game.abs_path(puzzle_folder + '/puzzles/') + for folder in os.listdir(path): + if not folder.isdigit(): + continue + abs_path = os.path.join(path, folder) + if os.path.isdir(abs_path): + BACKUPS['game_path'] = abs_path + BACKUPS['game_zip'] = zip_file = FakeZip(abs_path) + maps = load_backup(zip_file) + + BACKUPS['game'] = maps + refresh_game_details() + + +def backup_maps(maps): + """Copy the given maps to the backup.""" + back_zip = BACKUPS['backup_zip'] # type: ZipFile + + # Allow removing old maps when we overwrite objects + map_dict = { + p2c.path: p2c + for p2c in + BACKUPS['back'] + } + + # You can't remove files from a zip, so we need to create a new one! + # Here we'll just add entries into BACKUPS['back']. + # Also check for overwriting + for p2c in maps: + scr_path = p2c.path + '.jpg' + map_path = p2c.path + '.p2c' + if ( + map_path in zip_names(back_zip) or + scr_path in zip_names(back_zip) + ): + if not messagebox.askyesno( + title='Overwrite File?', + message='This filename is already in the backup.' + 'Do you wish to overwrite it? ' + '({})'.format(p2c.title), + parent=window, + icon=messagebox.QUESTION, + ): + continue + new_item = p2c.copy() + map_dict[p2c.path] = new_item + + BACKUPS['back'] = list(map_dict.values()) + refresh_back_details() + + +def save_backup(): + """Save the backup file.""" + # We generate it from scratch, since that's the only way to remove + # files. + new_zip_data = BytesIO() + new_zip = ZipFile(new_zip_data, 'w', compression=ZIP_LZMA) + + maps = [ + item.p2c + for item in + UI['back_details'].items + ] + + copy_loader.set_length('COPY', len(maps)) + + with copy_loader: + for p2c in maps: + old_zip = p2c.zip_file + map_path = p2c.path + '.p2c' + scr_path = p2c.path + '.jpg' + if scr_path in zip_names(old_zip): + with zip_open_bin(old_zip, scr_path) as f: + new_zip.writestr(scr_path, f.read()) + + with old_zip.open(map_path, 'r') as f: + new_zip.writestr(map_path, f.read()) + copy_loader.step('COPY') + + new_zip.close() # Finalize zip + + with open(BACKUPS['backup_path'], 'wb') as backup: + backup.write(new_zip_data.getvalue()) + BACKUPS['unsaved_file'] = new_zip_data + + # Remake the zipfile object, so it's open again. + BACKUPS['backup_zip'] = new_zip = ZipFile( + new_zip_data, + mode='w', + compression=ZIP_LZMA, + ) + + # Update the items, so they use this zip now. + for p2c in maps: + p2c.zip_file = new_zip + + +def restore_maps(maps): + """Copy the given maps to the game.""" + game_dir = BACKUPS['game_path'] + + copy_loader.set_length('COPY', len(maps)) + with copy_loader: + for p2c in maps: + back_zip = p2c.zip_file + scr_path = p2c.path + '.jpg' + map_path = p2c.path + '.p2c' + abs_scr = os.path.join(game_dir, scr_path) + abs_map = os.path.join(game_dir, map_path) + if ( + os.path.isfile(abs_scr) or + os.path.isfile(abs_map) + ): + if not messagebox.askyesno( + title='Overwrite File?', + message='This map is already in the game directory.' + 'Do you wish to overwrite it? ' + '({})'.format(p2c.title), + parent=window, + icon=messagebox.QUESTION, + ): + copy_loader.step('COPY') + continue + + if scr_path in zip_names(back_zip): + with zip_open_bin(back_zip, scr_path) as src: + with open(abs_scr, 'wb') as dest: + shutil.copyfileobj(src, dest) + + with zip_open_bin(back_zip, map_path) as src: + with open(abs_map, 'wb') as dest: + shutil.copyfileobj(src, dest) + + new_item = p2c.copy() + new_item.zip_file = FakeZip(game_dir) + BACKUPS['game'].append(new_item) + copy_loader.step('COPY') + + refresh_game_details() + + +def refresh_game_details(): + """Remake the items in the game maps list.""" + game = UI['game_details'] + game.remove_all() + game.add_items(*( + peti_map.make_item() + for peti_map in + BACKUPS['game'] + )) + + +def refresh_back_details(): + """Remake the items in the backup list.""" + backup = UI['back_details'] + backup.remove_all() + backup.add_items(*( + peti_map.make_item() + for peti_map in + BACKUPS['back'] + )) + + +def show_window(): + window.deiconify() + window.lift() + utils.center_win(window, TK_ROOT) + # Load our game data! + ui_refresh_game() + + +def ui_load_backup(): + """Prompt and load in a backup file.""" + file = filedialog.askopenfilename( + title='Load Backup', + filetypes=[('Backup zip', '.zip')], + ) + if not file: + return + + BACKUPS['backup_path'] = file + with open(file, 'rb') as f: + # Read the backup zip into memory! + data = f.read() + BACKUPS['unsaved_file'] = unsaved = BytesIO(data) + + BACKUPS['backup_zip'] = zip_file = ZipFile( + unsaved, + mode='a', + compression=ZIP_LZMA, + ) + BACKUPS['back'] = load_backup(zip_file) + + BACKUPS['backup_name'] = os.path.basename(file) + backup_name.set(BACKUPS['backup_name']) + + refresh_back_details() + + +def ui_new_backup(): + """Create a new backup file.""" + BACKUPS['back'].clear() + BACKUPS['backup_name'] = None + BACKUPS['backup_path'] = None + backup_name.set('Unsaved Backup') + BACKUPS['unsaved_file'] = unsaved = BytesIO() + BACKUPS['backup_zip'] = ZipFile( + unsaved, + mode='w', + compression=ZIP_LZMA, + ) + + +def ui_save_backup(): + """Save a backup.""" + if BACKUPS['backup_path'] is None: + # No backup path, prompt first + ui_save_backup_as() + return + + save_backup() + + +def ui_save_backup_as(): + """Prompt for a name, and then save a backup.""" + path = filedialog.asksaveasfilename( + title='Save Backup As', + filetypes=[('Backup zip', '.zip')], + ) + if not path: + return + if not path.endswith('.zip'): + path += '.zip' + + BACKUPS['backup_path'] = path + BACKUPS['backup_name'] = os.path.basename(path) + backup_name.set(BACKUPS['backup_name']) + ui_save_backup() + + +def ui_refresh_game(): + """Reload the game maps list.""" + if gameMan.selected_game is not None: + load_game(gameMan.selected_game) + + +def ui_backup_sel(): + """Backup selected maps.""" + backup_maps([ + item.p2c + for item in + UI['game_details'].items + if item.state + ]) + + +def ui_backup_all(): + """Backup all maps.""" + backup_maps([ + item.p2c + for item in + UI['game_details'].items + ]) + + +def ui_restore_sel(): + """Restore selected maps.""" + restore_maps([ + item.p2c + for item in + UI['back_details'].items + if item.state + ]) + + +def ui_restore_all(): + """Backup all maps.""" + restore_maps([ + item.p2c + for item in + UI['back_details'].items + ]) + + +def init(): + """Initialise all widgets in the given window.""" + for cat, btn_text in [ + ('back_', 'Restore:'), + ('game_', 'Backup:'), + ]: + UI[cat + 'frame'] = frame = ttk.Frame( + window, + ) + UI[cat + 'title_frame'] = title_frame = ttk.Frame( + frame, + ) + title_frame.grid(row=0, column=0, sticky='EW') + UI[cat + 'title'] = ttk.Label( + title_frame, + font='TkHeadingFont', + ) + UI[cat + 'title'].grid(row=0, column=0) + title_frame.rowconfigure(0, weight=1) + title_frame.columnconfigure(0, weight=1) + + UI[cat + 'details'] = CheckDetails( + frame, + headers=HEADERS, + ) + UI[cat + 'details'].grid(row=1, column=0, sticky='NSEW') + frame.rowconfigure(1, weight=1) + frame.columnconfigure(0, weight=1) + + button_frame = ttk.Frame( + frame, + ) + button_frame.grid(column=0, row=2) + ttk.Label(button_frame, text=btn_text).grid(row=0, column=0) + UI[cat + 'btn_all'] = ttk.Button( + button_frame, + text='All', + width=3, + ) + UI[cat + 'btn_sel'] = ttk.Button( + button_frame, + text='Checked', + width=8, + ) + UI[cat + 'btn_all'].grid(row=0, column=1) + UI[cat + 'btn_sel'].grid(row=0, column=2) + + UI[cat + 'btn_del'] = ttk.Button( + button_frame, + text='Delete Checked', + width=14, + ) + UI[cat + 'btn_del'].grid(row=1, column=0, columnspan=3) + + utils.add_mousewheel( + UI[cat + 'details'].wid_canvas, + UI[cat + 'frame'], + ) + + UI['game_refresh'] = ttk.Button( + UI['game_title_frame'], + image=img.png('icons/tool_sub'), + command=ui_refresh_game, + ) + UI['game_refresh'].grid(row=0, column=1, sticky='E') + add_tooltip( + UI['game_refresh'], + "Reload the map list.", + ) + + UI['game_title']['textvariable'] = game_name + UI['back_title']['textvariable'] = backup_name + + UI['game_btn_all']['command'] = ui_backup_all + UI['game_btn_sel']['command'] = ui_backup_sel + + UI['back_btn_all']['command'] = ui_restore_all + UI['back_btn_sel']['command'] = ui_restore_sel + + UI['back_frame'].grid(row=1, column=0, sticky='NSEW') + ttk.Separator(orient=tk.VERTICAL).grid( + row=1, column=1, sticky='NS', padx=5, + ) + UI['game_frame'].grid(row=1, column=2, sticky='NSEW') + + window.rowconfigure(1, weight=1) + window.columnconfigure(0, weight=1) + window.columnconfigure(2, weight=1) + + +def init_application(): + """Initialise the standalone application.""" + global window + window = TK_ROOT + init() + + UI['bar'] = bar = tk.Menu(TK_ROOT) + window.option_add('*tearOff', False) + + gameMan.load() + ui_new_backup() + + # UI.py isn't present, so we use this callback + gameMan.setgame_callback = load_game + + if utils.MAC: + # Name is used to make this the special 'BEE2' menu item + file_menu = menus['file'] = tk.Menu(bar, name='apple') + else: + file_menu = menus['file'] = tk.Menu(bar) + file_menu.add_command(label='New Backup', command=ui_new_backup) + file_menu.add_command(label='Open Backup', command=ui_load_backup) + file_menu.add_command(label='Save Backup', command=ui_save_backup) + file_menu.add_command(label='Save Backup As', command=ui_save_backup_as) + + bar.add_cascade(menu=file_menu, label='File') + + game_menu = menus['game'] = tk.Menu(bar) + + game_menu.add_command(label='Add Game', command=gameMan.add_game) + game_menu.add_command(label='Remove Game', command=gameMan.remove_game) + game_menu.add_separator() + + bar.add_cascade(menu=game_menu, label='Game') + window['menu'] = bar + + gameMan.add_menu_opts(game_menu) + gameMan.game_menu = game_menu + + +def init_backup_settings(): + """Initialise the auto-backup settings widget.""" + from BEE2_config import GEN_OPTS + check_var = tk.IntVar( + value=GEN_OPTS.get_bool('General', 'enable_auto_backup') + ) + count_value = GEN_OPTS.get_int('General', 'auto_backup_count', 1) + back_dir = GEN_OPTS.get_val('Directories', 'backup_loc', 'backups/') + + def check_callback(): + GEN_OPTS['General']['enable_auto_backup'] = utils.bool_as_int( + check_var.get() + ) + + def count_callback(): + GEN_OPTS['General']['enable_auto_backup'] = str(count.value) + + def directory_callback(path): + GEN_OPTS['Directories']['backup_loc'] = path + + UI['auto_frame'] = frame = ttk.LabelFrame( + window, + ) + UI['auto_enable'] = enable_check = ttk.Checkbutton( + frame, + text='Automatic Backup After Export', + variable=check_var, + command=check_callback, + ) + + frame['labelwidget'] = enable_check + frame.grid(row=2, column=0, columnspan=3) + + dir_frame = ttk.Frame( + frame, + ) + dir_frame.grid(row=0, column=0) + + ttk.Label( + dir_frame, + text='Directory', + ).grid(row=0, column=0) + + UI['auto_dir'] = tk_tools.FileField( + dir_frame, + loc=back_dir, + is_dir=True, + callback=directory_callback, + ) + UI['auto_dir'].grid(row=1, column=0) + + count_frame = ttk.Frame( + frame, + ) + count_frame.grid(row=0, column=1) + ttk.Label( + count_frame, + text='Keep (Per Game):' + ).grid(row=0, column=0) + + count = tk_tools.ttk_Spinbox( + count_frame, + range=range(1, 50), + command=count_callback, + ) + count.grid(row=1, column=0) + count.value = count_value + + +def init_toplevel(): + """Initialise the window as part of the BEE2.""" + global window + window = tk.Toplevel(TK_ROOT) + window.transient(TK_ROOT) + window.withdraw() + + def quit_command(): + from BEE2_config import GEN_OPTS + window.withdraw() + GEN_OPTS.save_check() + + # Don't destroy window when quit! + window.protocol("WM_DELETE_WINDOW", quit_command) + + init() + init_backup_settings() + + # When embedded in the BEE2, use regular buttons and a dropdown! + toolbar_frame = ttk.Frame( + window, + ) + ttk.Button( + toolbar_frame, + text='New Backup', + command=ui_new_backup, + width=14, + ).grid(row=0, column=0) + + ttk.Button( + toolbar_frame, + text='Open Backup', + command=ui_load_backup, + width=13, + ).grid(row=0, column=1) + + ttk.Button( + toolbar_frame, + text='Save Backup', + command=ui_save_backup, + width=11, + ).grid(row=0, column=2) + + ttk.Button( + toolbar_frame, + text='.. As', + command=ui_save_backup_as, + width=5, + ).grid(row=0, column=3) + + toolbar_frame.grid(row=0, column=0, columnspan=3, sticky='W') + + ui_new_backup() + + +if __name__ == '__main__': + # Run this standalone. + init_application() + + TK_ROOT.deiconify() + + def fix_details(): + # It takes a while before the detail headers update positions, + # so delay a refresh call. + TK_ROOT.update_idletasks() + UI['game_details'].refresh() + TK_ROOT.after(500, fix_details) + + TK_ROOT.mainloop() From 806561d8c91ae61a3d1c20e69aefab56beb9ad95 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 11:26:13 +1000 Subject: [PATCH 61/91] Add whitelist and blacklist funcs to utils --- src/utils.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/utils.py b/src/utils.py index 26aefd4da..0b6e20a0e 100644 --- a/src/utils.py +++ b/src/utils.py @@ -321,7 +321,7 @@ def is_identifier(name, forbidden='{}\'"'): return False return True -FILE_CHARS = string.ascii_letters + string.digits + '-_ .|' +FILE_CHARS = set(string.ascii_letters + string.digits + '-_ .|') def is_plain_text(name, valid_chars=FILE_CHARS): @@ -334,6 +334,24 @@ def is_plain_text(name, valid_chars=FILE_CHARS): return True +def whitelist(string, valid_chars=FILE_CHARS, rep_char='_'): + """Replace any characters not in the whitelist with the replacement char.""" + chars = list(string) + for ind, char in enumerate(chars): + if char not in valid_chars: + chars[ind] = rep_char + return ''.join(chars) + + +def blacklist(string, invalid_chars='', rep_char='_'): + """Replace any characters in the blacklist with the replacement char.""" + chars = list(string) + for ind, char in enumerate(chars): + if char in invalid_chars: + chars[ind] = rep_char + return ''.join(chars) + + def get_indent(line: str): """Return the whitespace which this line starts with. From 425dd0858173bcbd3a2829573548cfd8631f9d20 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 11:26:35 +1000 Subject: [PATCH 62/91] Generate a backup_tool exe as well --- src/compile_BEE2.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/compile_BEE2.py b/src/compile_BEE2.py index 112e953d5..ab4e88344 100644 --- a/src/compile_BEE2.py +++ b/src/compile_BEE2.py @@ -105,6 +105,13 @@ base=base, icon=ico_path, compress=True, + ), + Executable( + 'backup.py', + base=base, + icon=ico_path, + compress=True, + targetName='backup_tool' + ('.exe' if utils.WIN else ''), ) ], ) From ae0a1ce4a7eb9e447e2d9e9bc0c40eba88361cb5 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 11:35:59 +1000 Subject: [PATCH 63/91] Add auto-backup function Whenever items are exported a backup of all maps can be performed, keeping a number of earlier backups. --- src/backup.py | 120 ++++++++++++++++++++++++++++++++++++++++++++----- src/gameMan.py | 11 +++-- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/backup.py b/src/backup.py index b881f1e7c..95da2814b 100644 --- a/src/backup.py +++ b/src/backup.py @@ -1,6 +1,7 @@ """Backup and restore P2C maps. """ +import string import tkinter as tk from tkinter import ttk from tkinter import filedialog @@ -26,13 +27,21 @@ import tk_tools import gameMan - +# The backup window - either a toplevel, or TK_ROOT. window = None # type: tk.Toplevel -UI = {} +UI = {} # Holds all the widgets menus = {} # For standalone application, generate menu bars +# Stage name for the exporting screen +AUTO_BACKUP_STAGE = 'BACKUP_ZIP' + +# Characters allowed in the backup filename +BACKUP_CHARS = set(string.ascii_letters + string.digits + '_-.') +# Format for the backup filename +AUTO_BACKUP_FILE = 'back_{game}{ind}.zip' + HEADERS = ['Name', 'Mode', 'Date'] # The game subfolder where puzzles are located @@ -63,6 +72,7 @@ backup_name = tk.StringVar() game_name = tk.StringVar() +# Loadscreens used as basic progress bars copy_loader = LoadScreen( ('COPY', ''), title_text='Copying maps', @@ -225,23 +235,38 @@ def load_backup(zip_file): return maps -def load_game(game: gameMan.Game): +def load_game(game: 'gameMan.Game'): """Callback for gameMan, load in files for a game.""" game_name.set(game.name) + puzz_path = find_puzzles(game) + if puzz_path: + BACKUPS['game_path'] = puzz_path + BACKUPS['game_zip'] = zip_file = FakeZip(puzz_path) + maps = load_backup(zip_file) + + BACKUPS['game'] = maps + refresh_game_details() + + +def find_puzzles(game: 'gameMan.Game'): + """Find the path for the p2c files.""" + # The puzzles are located in: + # /portal2/puzzles/ + # 'portal2' changes with different games. + puzzle_folder = PUZZLE_FOLDERS.get(str(game.steamID), 'portal2') path = game.abs_path(puzzle_folder + '/puzzles/') + for folder in os.listdir(path): + # The steam ID is all digits, so look for a folder with only digits + # in the name if not folder.isdigit(): continue abs_path = os.path.join(path, folder) if os.path.isdir(abs_path): - BACKUPS['game_path'] = abs_path - BACKUPS['game_zip'] = zip_file = FakeZip(abs_path) - maps = load_backup(zip_file) - - BACKUPS['game'] = maps - refresh_game_details() + return abs_path + return None def backup_maps(maps): @@ -281,6 +306,77 @@ def backup_maps(maps): refresh_back_details() +def auto_backup(game: 'gameMan.Game', loader: LoadScreen): + """Perform an automatic backup for the given game. + + We do this seperately since we don't need to read the property files. + """ + from BEE2_config import GEN_OPTS + if not GEN_OPTS.get_bool('General', 'enable_auto_backup'): + # Don't backup! + loader.skip_stage(AUTO_BACKUP_STAGE) + return + + folder = find_puzzles(game) + if not folder: + loader.skip_stage(AUTO_BACKUP_STAGE) + return + + # Keep this many previous + extra_back_count = GEN_OPTS.get_int('General', 'auto_backup_count', 0) + + to_backup = os.listdir(folder) + backup_dir = GEN_OPTS.get_val('Directories', 'backup_loc', 'backups/') + + os.makedirs(backup_dir, exist_ok=True) + + # A version of the name stripped of special characters + # Allowed: a-z, A-Z, 0-9, '_-.' + safe_name = utils.whitelist( + game.name, + valid_chars=BACKUP_CHARS, + ) + + loader.set_length(AUTO_BACKUP_STAGE, len(to_backup)) + + if extra_back_count: + back_files = [ + AUTO_BACKUP_FILE.format(game=safe_name, ind='') + ] + [ + AUTO_BACKUP_FILE.format(game=safe_name, ind='_'+str(i+1)) + for i in range(extra_back_count) + ] + # Move each file over by 1 index, ignoring missing ones + # We need to reverse to ensure we don't overwrite any zips + for old_name, new_name in reversed( + list(zip(back_files, back_files[1:])) + ): + print('Moving:', old_name, '->', new_name) + old_name = os.path.join(backup_dir, old_name) + new_name = os.path.join(backup_dir, new_name) + try: + # Overwrites! + shutil.copyfile(old_name, new_name) + os.remove(old_name) + except FileNotFoundError: + pass + + final_backup = os.path.join( + backup_dir, + AUTO_BACKUP_FILE.format(game=safe_name, ind=''), + ) + print('Writing backup to "{}"'.format(final_backup)) + with open(final_backup, 'wb') as f: + with ZipFile(f, mode='w', compression=ZIP_LZMA) as zip_file: + for file in to_backup: + zip_file.write( + os.path.join(folder, file), + file, + ZIP_LZMA, + ) + loader.step(AUTO_BACKUP_STAGE) + + def save_backup(): """Save the backup file.""" # We generate it from scratch, since that's the only way to remove @@ -650,7 +746,7 @@ def init_backup_settings(): check_var = tk.IntVar( value=GEN_OPTS.get_bool('General', 'enable_auto_backup') ) - count_value = GEN_OPTS.get_int('General', 'auto_backup_count', 1) + count_value = GEN_OPTS.get_int('General', 'auto_backup_count', 0) back_dir = GEN_OPTS.get_val('Directories', 'backup_loc', 'backups/') def check_callback(): @@ -659,7 +755,7 @@ def check_callback(): ) def count_callback(): - GEN_OPTS['General']['enable_auto_backup'] = str(count.value) + GEN_OPTS['General']['auto_backup_count'] = str(count.value) def directory_callback(path): GEN_OPTS['Directories']['backup_loc'] = path @@ -706,7 +802,7 @@ def directory_callback(path): count = tk_tools.ttk_Spinbox( count_frame, - range=range(1, 50), + range=range(50), command=count_callback, ) count.grid(row=1, column=0) diff --git a/src/gameMan.py b/src/gameMan.py index f16bcd3ca..556d643a1 100644 --- a/src/gameMan.py +++ b/src/gameMan.py @@ -20,9 +20,10 @@ import utils import loadScreen import extract_packages +import backup all_games = [] -selected_game = None +selected_game = None # type: Game selectedGame_radio = IntVar(value=0) game_menu = None @@ -69,6 +70,7 @@ # The progress bars used when exporting data into a game export_screen = loadScreen.LoadScreen( ('BACK', 'Backup Original Files'), + (backup.AUTO_BACKUP_STAGE, 'Backup Puzzles'), ('CONF', 'Generate Config Files'), ('COMP', 'Copy Compiler'), ('RES', 'Copy Resources'), @@ -147,7 +149,7 @@ def dlc_priority(self): def abs_path(self, path): return os.path.normcase(os.path.join(self.root, path)) - def add_editor_sounds(self, sounds: Property): + def add_editor_sounds(self, sounds): """Add soundscript items so they can be used in the editor.""" # PeTI only loads game_sounds_editor, so we must modify that. # First find the highest-priority file @@ -469,6 +471,9 @@ def export( shutil.copy(item_path, backup_path) export_screen.step('BACK') + # Backup puzzles, if desired + backup.auto_backup(selected_game, export_screen) + # This is the connections "heart" icon and "error" icon editoritems += style.editor.find_key("Renderables", []) @@ -722,7 +727,7 @@ def remove_game(_=None): config.save() if not all_games: - UI.quit_application() # If we have no games, nothing can be done + quit_application() # If we have no games, nothing can be done selected_game = all_games[0] selectedGame_radio.set(0) From 5bb3e51cadd1200bfaaabb6ee28a63d688f6ec30 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 14:00:08 +1000 Subject: [PATCH 64/91] Fix incorrect loading bar counts - EditorSound does not have an image. --- src/packageLoader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packageLoader.py b/src/packageLoader.py index 91e37bded..e60cbfce6 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -1015,7 +1015,7 @@ def add_over(self, override): self.trigger_mats.append(item) -@pak_object('EditorSound') +@pak_object('EditorSound', has_img=False) class EditorSound: """Add sounds that are usable in the editor. From a0994efa0a999682e91af9aea9928c198f4480bd Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 15:35:11 +1000 Subject: [PATCH 65/91] Fix template face retexturing - We need to add the faces, not the solids to IGNORED_FACES --- src/conditions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/conditions.py b/src/conditions.py index 7e7a81055..013af9de9 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -730,7 +730,10 @@ def import_template( # Don't let these get retextured normally - that should be # done by retexture_template(), if at all! - vbsp.IGNORED_FACES.update(new_world, new_detail) + for solid in new_world: + vbsp.IGNORED_FACES.update(solid.sides) + for solid in new_detail: + vbsp.IGNORED_FACES.update(solid.sides) return new_world, detail_ent From 57204c831fd37f4f7f7c82587f68077d70218cf5 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 16:08:07 +1000 Subject: [PATCH 66/91] Use the subpane.make_tool_button() command to create the refresh-counts button --- src/CompilerPane.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/CompilerPane.py b/src/CompilerPane.py index 59b459c88..67c858346 100644 --- a/src/CompilerPane.py +++ b/src/CompilerPane.py @@ -11,8 +11,8 @@ import img as png from BEE2_config import ConfigFile, GEN_OPTS -from SubPane import SubPane from tooltip import add_tooltip +import SubPane import utils MAX_ENTS = 2048 @@ -254,7 +254,7 @@ def make_pane(tool_frame): """ global window - window = SubPane( + window = SubPane.SubPane( TK_ROOT, options=GEN_OPTS, title='Compile Opt', @@ -546,11 +546,12 @@ def make_pane(tool_frame): ) UI['count_over'].grid(row=3, column=0, sticky=EW, padx=5) - ttk.Button( + UI['refresh_counts'] = SubPane.make_tool_button( count_frame, - image=png.png('icons/tool_sub'), - command=refresh_counts, - ).grid(row=3, column=1) + png.png('icons/tool_sub', resize_to=16), + refresh_counts, + ) + UI['refresh_counts'].grid(row=3, column=1) ttk.Label( count_frame, From 9d288788cee5534fb122ee3d8f1f4016358b866e Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Mon, 16 Nov 2015 18:27:18 +1000 Subject: [PATCH 67/91] Start work on fizzler overlay borders --- src/utils.py | 11 +++++++++ src/vbsp.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/utils.py b/src/utils.py index 0b6e20a0e..4207fb69b 100644 --- a/src/utils.py +++ b/src/utils.py @@ -711,6 +711,17 @@ def bbox(*points): bbox_max.max(point) return bbox_min, bbox_max + def axis(self): + """For a normal vector, return the axis it is on. + + This will not function correctly if not a on-axis normal vector! + """ + return ( + 'x' if self.x != 0 else + 'y' if self.y != 0 else + 'z' + ) + def __add__(self, other: Union['Vec', Vec_tuple, float]) -> 'Vec': """+ operation. diff --git a/src/vbsp.py b/src/vbsp.py index b47b5fbd5..ae3ae7a32 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -90,6 +90,7 @@ ('', 'special.black_gap'), ('', 'special.goo_wall'), ('', 'special.edge_special'), + ('', 'special.fizz_border'), # And these defaults have the extra scale information, which isn't # in the maps. @@ -169,6 +170,8 @@ def __str__(self): # texture matching "tile_texture_lock": "1", + "flip_fizz_border": "0", # Swap U/V for fizz border overlays + "force_fizz_reflect": "0", # Force fast reflections on fizzlers "force_brush_reflect": "0", # Force fast reflections on func_brushes @@ -476,6 +479,68 @@ def add_voice(inst): ) +@conditions.meta_cond(priority=-250) +def add_fizz_borders(_): + """Generate overlays at the top and bottom of fizzlers. + + This is used in 50s and BTS styles. + """ + tex = settings['textures']['special.fizz_border'] + if tex == ['']: + return + + flip_uv = get_bool_opt('flip_fizz_border') + + # First, figure out the orientation of every fizzler via their model. + fizz_directions = {} + for inst in VMF.by_class['func_instance']: + if '_modelStart' not in inst['targetname', '']: + continue + name = inst['targetname'].rsplit('_modelStart', 1)[0] + '_brush' + # Once per fizzler only! + if name not in fizz_directions: + fizz_directions[name] = ( + # Normal direction of surface + Vec(1, 0, 0).rotate_by_str(inst['angles']).axis(), + # 'Horizontal' direction (for vertical fizz attached to walls) + Vec(0, 0, 1).rotate_by_str(inst['angles']).axis(), + # 'Vertical' direction (for vertical fizz attached to walls) + Vec(0, 1, 0).rotate_by_str(inst['angles']).axis(), + ) + + for brush_ent in (VMF.by_class['trigger_portal_cleanser'] | + VMF.by_class['func_brush']): + try: + normal, horiz, vert = fizz_directions[brush_ent['targetname']] + except KeyError: + continue + + bbox_min, bbox_max = brush_ent.get_bbox() + dimensions = bbox_max - bbox_min + + # We need to snap the axis normal_axis to the grid, since it could + # be forward or back. + min_pos = bbox_min.copy() + min_pos[normal] = min_pos[normal] // 128 * 128 + 64 + + max_pos = min_pos.copy() + max_pos[vert] += 128 + + min_faces = [] + max_faces = [] + + print(brush_ent['targetname'], dimensions, normal, horiz, vert) + for offset in range(64, int(dimensions[horiz]) - 1, 128): + # Each position on top or bottom + max_pos[horiz] = min_pos[horiz] = bbox_min[horiz] + offset + solid = conditions.SOLIDS.get(min_pos.as_tuple()) + if solid is not None: + min_faces.append(solid.face) + solid = conditions.SOLIDS.get(max_pos.as_tuple()) + if solid is not None: + max_faces.append(solid.face) + + @conditions.meta_cond(priority=-200, only_once=False) def fix_fizz_models(inst): """Fix some bugs with fizzler model instances. From a6b94d62f8ef46c20bfe3f12c1b50f2ba1e9fcab Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Tue, 17 Nov 2015 18:37:23 +1000 Subject: [PATCH 68/91] Generate fizzler overlays --- src/utils.py | 19 ++++++++++++++ src/vbsp.py | 71 +++++++++++++++++++++++++++++++++++++++++---------- src/vmfLib.py | 64 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 14 deletions(-) diff --git a/src/utils.py b/src/utils.py index 4207fb69b..00382ae4c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -722,6 +722,25 @@ def axis(self): 'z' ) + def to_angle(self, roll=0): + """Convert a normal to a Source Engine angle.""" + # Pitch is applied first, so we need to reconstruct the x-value + horiz_dist = math.sqrt(self.x ** 2 + self.y ** 2) + return Vec( + math.degrees(math.atan2(self.z, horiz_dist)), + math.degrees(math.atan2(self.y, self.x)) % 360, + roll, + ) + + def __abs__(self): + """Performing abs() on a Vec takes the absolute value of all axes.""" + return Vec( + abs(self.x), + abs(self.y), + abs(self.z), + ) + + def __add__(self, other: Union['Vec', Vec_tuple, float]) -> 'Vec': """+ operation. diff --git a/src/vbsp.py b/src/vbsp.py index ae3ae7a32..d501d3f63 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -17,6 +17,10 @@ import instanceLocs import conditions +from typing import ( + Dict, Tuple, +) + # Configuration data extracted from VBSP_config settings = { @@ -170,7 +174,8 @@ def __str__(self): # texture matching "tile_texture_lock": "1", - "flip_fizz_border": "0", # Swap U/V for fizz border overlays + "fizz_border_vertical": "0", # The texture is oriented vertically + "fizz_border_thickness": "8", # The width of the overlays "force_fizz_reflect": "0", # Force fast reflections on fizzlers "force_brush_reflect": "0", # Force fast reflections on func_brushes @@ -489,10 +494,11 @@ def add_fizz_borders(_): if tex == ['']: return - flip_uv = get_bool_opt('flip_fizz_border') + flip_uv = get_bool_opt('fizz_border_vertical') + overlay_thickness = utils.conv_int(get_opt('fizz_border_thickness'), 8) # First, figure out the orientation of every fizzler via their model. - fizz_directions = {} + fizz_directions = {} # type: Dict[str, Tuple[Vec, Vec, Vec]] for inst in VMF.by_class['func_instance']: if '_modelStart' not in inst['targetname', '']: continue @@ -501,19 +507,22 @@ def add_fizz_borders(_): if name not in fizz_directions: fizz_directions[name] = ( # Normal direction of surface - Vec(1, 0, 0).rotate_by_str(inst['angles']).axis(), + Vec(1, 0, 0).rotate_by_str(inst['angles']), # 'Horizontal' direction (for vertical fizz attached to walls) - Vec(0, 0, 1).rotate_by_str(inst['angles']).axis(), + Vec(0, 0, 1).rotate_by_str(inst['angles']), # 'Vertical' direction (for vertical fizz attached to walls) - Vec(0, 1, 0).rotate_by_str(inst['angles']).axis(), + Vec(0, 1, 0).rotate_by_str(inst['angles']), ) for brush_ent in (VMF.by_class['trigger_portal_cleanser'] | VMF.by_class['func_brush']): try: - normal, horiz, vert = fizz_directions[brush_ent['targetname']] + norm, horiz, vert = fizz_directions[brush_ent['targetname']] except KeyError: continue + norm_dir = norm.axis() + horiz_dir = horiz.axis() + vert_dir = vert.axis() bbox_min, bbox_max = brush_ent.get_bbox() dimensions = bbox_max - bbox_min @@ -521,18 +530,21 @@ def add_fizz_borders(_): # We need to snap the axis normal_axis to the grid, since it could # be forward or back. min_pos = bbox_min.copy() - min_pos[normal] = min_pos[normal] // 128 * 128 + 64 + min_pos[norm_dir] = min_pos[norm_dir] // 128 * 128 + 64 max_pos = min_pos.copy() - max_pos[vert] += 128 + max_pos[vert_dir] += 128 min_faces = [] max_faces = [] - print(brush_ent['targetname'], dimensions, normal, horiz, vert) - for offset in range(64, int(dimensions[horiz]) - 1, 128): - # Each position on top or bottom - max_pos[horiz] = min_pos[horiz] = bbox_min[horiz] + offset + overlay_len = int(dimensions[horiz_dir]) + + for offset in range(64, overlay_len, 128): + # Each position on top or bottom, inset 64 from each end + min_pos[horiz_dir] = bbox_min[horiz_dir] + offset + max_pos[horiz_dir] = min_pos[horiz_dir] + solid = conditions.SOLIDS.get(min_pos.as_tuple()) if solid is not None: min_faces.append(solid.face) @@ -540,6 +552,39 @@ def add_fizz_borders(_): if solid is not None: max_faces.append(solid.face) + if min_faces: + min_origin = bbox_min.copy() + min_origin[norm_dir] += 1 + min_origin[horiz_dir] += overlay_len/2 + min_origin[vert_dir] += 16 + VLib.make_overlay( + VMF, + normal=abs(vert), + origin=min_origin, + uax=horiz * overlay_len, + vax=norm * overlay_thickness, + material=random.choice(tex), + surfaces=min_faces, + v_repeat=overlay_len / 128, + swap=flip_uv, + ) + if max_faces: + max_origin = bbox_max.copy() + max_origin[norm_dir] -= 1 + max_origin[horiz_dir] -= overlay_len/2 + max_origin[vert_dir] -= 16 + VLib.make_overlay( + VMF, + normal=-abs(vert), + origin=max_origin, + uax=horiz * overlay_len, + vax=norm * overlay_thickness, + material=random.choice(tex), + surfaces=max_faces, + v_repeat=overlay_len / 128, + swap=flip_uv, + ) + @conditions.meta_cond(priority=-200, only_once=False) def fix_fizz_models(inst): diff --git a/src/vmfLib.py b/src/vmfLib.py index 9194cbc5a..6c23fa13f 100644 --- a/src/vmfLib.py +++ b/src/vmfLib.py @@ -13,7 +13,7 @@ from typing import ( Optional, Union, - Dict, List, Tuple, Set, Iterator, + Dict, List, Tuple, Set, Iterable, ) # Used to set the defaults for versioninfo @@ -94,6 +94,68 @@ def overlay_bounds(over): ) +def make_overlay( + vmf: 'VMF', + normal: Vec, + origin: Vec, + uax: Vec, + vax: Vec, + material: str, + surfaces: Iterable['Side'], + u_repeat=1, + v_repeat=1, + swap=False, + ) -> 'Entity': + """Generate an overlay on an axis-aligned surface. + + - origin is the center point of the overlay. + - uax is the direction and distance for the texture's width ('right'). + - vax is the direction and distance for the texture's height ('up'). + - normal is the normal of the surfaces. + - material is the material used. + - u_ and v_repeat define how many times to repeat the texture in that + direction. + - If swap is true, the texture will be rotated 90. + """ + if swap: + uax, vax = vax, uax + + u_dist = uax.mag()/2 + v_dist = vax.mag()/2 + basis_u = uax.norm() + basis_v = vax.norm() + + if normal.x < 0 or normal.y < 0: + basis_v *= -1 + if normal.z < 0: + basis_u *= -1 + + + return vmf.create_ent( + classname='info_overlay', + angles='0 0 0', # Not actually used by VBSP! + origin=origin.join(' '), + + basisNormal=normal.join(' '), + basisOrigin=origin.join(' '), + basisU=basis_u.join(' '), + basisV=basis_v.join(' '), + + material=material, + sides=' '.join(str(side.id) for side in surfaces), + + startU='0', + startV='0', + endU=str(u_repeat), + endV=str(v_repeat), + + uv0='-{} -{} 0'.format(u_dist, v_dist), + uv1='-{} {} 0'.format(u_dist, v_dist), + uv2='{} {} 0'.format(u_dist, v_dist), + uv3='{} -{} 0'.format(u_dist, v_dist), + ) + + class CopySet(set): """Modified version of a Set which allows modification during iteration. From bfa6136515677b581742945da29c96787ffb089a Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Tue, 17 Nov 2015 19:01:37 +1000 Subject: [PATCH 69/91] Fix some things for BTS hazard stripes --- src/vbsp.py | 15 +++++++++++++-- src/vmfLib.py | 3 ++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/vbsp.py b/src/vbsp.py index d501d3f63..8cfc77e01 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -176,6 +176,7 @@ def __str__(self): "fizz_border_vertical": "0", # The texture is oriented vertically "fizz_border_thickness": "8", # The width of the overlays + "fizz_border_repeat": "128", # The width lengthways "force_fizz_reflect": "0", # Force fast reflections on fizzlers "force_brush_reflect": "0", # Force fast reflections on func_brushes @@ -496,6 +497,7 @@ def add_fizz_borders(_): flip_uv = get_bool_opt('fizz_border_vertical') overlay_thickness = utils.conv_int(get_opt('fizz_border_thickness'), 8) + overlay_repeat = utils.conv_int(get_opt('fizz_border_repeat'), 128) # First, figure out the orientation of every fizzler via their model. fizz_directions = {} # type: Dict[str, Tuple[Vec, Vec, Vec]] @@ -552,6 +554,13 @@ def add_fizz_borders(_): if solid is not None: max_faces.append(solid.face) + if flip_uv: + u_rep = 1 + v_rep = overlay_len / overlay_repeat + else: + u_rep = overlay_len / overlay_repeat + v_rep = 1 + if min_faces: min_origin = bbox_min.copy() min_origin[norm_dir] += 1 @@ -565,7 +574,8 @@ def add_fizz_borders(_): vax=norm * overlay_thickness, material=random.choice(tex), surfaces=min_faces, - v_repeat=overlay_len / 128, + u_repeat=u_rep, + v_repeat=v_rep, swap=flip_uv, ) if max_faces: @@ -581,7 +591,8 @@ def add_fizz_borders(_): vax=norm * overlay_thickness, material=random.choice(tex), surfaces=max_faces, - v_repeat=overlay_len / 128, + u_repeat=u_rep, + v_repeat=v_rep, swap=flip_uv, ) diff --git a/src/vmfLib.py b/src/vmfLib.py index 6c23fa13f..3ed997e95 100644 --- a/src/vmfLib.py +++ b/src/vmfLib.py @@ -105,13 +105,14 @@ def make_overlay( u_repeat=1, v_repeat=1, swap=False, + render_order=0 ) -> 'Entity': """Generate an overlay on an axis-aligned surface. - origin is the center point of the overlay. - uax is the direction and distance for the texture's width ('right'). - vax is the direction and distance for the texture's height ('up'). - - normal is the normal of the surfaces. + - normal is the normal of the surfaces (axis-aligned). - material is the material used. - u_ and v_repeat define how many times to repeat the texture in that direction. From 101b0de7e52fa4a3d83b93a1a39cffd25fad65ae Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Wed, 18 Nov 2015 13:23:01 +1000 Subject: [PATCH 70/91] Fix retexturing of template faces --- src/conditions.py | 11 ++++++----- src/vbsp.py | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index 6d631f3e8..7e5b012ca 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -697,7 +697,7 @@ def import_template( returned instead of an invalid entity. """ import vbsp - orig_world, orig_detail = TEMPLATES[temp_name] + orig_world, orig_detail = TEMPLATES[temp_name.casefold()] new_world = [] new_detail = [] @@ -710,6 +710,11 @@ def import_template( brush.localise(origin, angles) new_list.append(brush) + # Don't let these get retextured normally - that should be + # done by retexture_template(), if at all! + for brush in new_world + new_detail: + vbsp.IGNORED_FACES.update(brush.sides) + if force_type is TEMP_TYPES.detail: new_detail.extend(new_world) new_world.clear() @@ -727,10 +732,6 @@ def import_template( else: detail_ent = None - # Don't let these get retextured normally - that should be - # done by retexture_template(), if at all! - vbsp.IGNORED_FACES.update(new_world, new_detail) - return new_world, detail_ent diff --git a/src/vbsp.py b/src/vbsp.py index b47b5fbd5..3890e72d6 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -1926,6 +1926,9 @@ def change_func_brush(): is_grating = False delete_brush = False for side in brush.sides(): + if side in IGNORED_FACES: + continue + if side.mat.casefold() == "anim_wp/framework/squarebeams": side.mat = get_tex(edge_tex) fix_squarebeams( From d215f645b03684e46182815fcca2e260b8b615b1 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Wed, 18 Nov 2015 13:23:18 +1000 Subject: [PATCH 71/91] Use templates for cutout tiles --- src/cutoutTile.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/cutoutTile.py b/src/cutoutTile.py index 52876ace2..8c304004c 100644 --- a/src/cutoutTile.py +++ b/src/cutoutTile.py @@ -29,6 +29,9 @@ FORCE_LOCATIONS = set() +# The template used to seal sides open to the void. +SIDE_BRUSH_TEMPLATE = 'BEE2_CUTOUT_TILE_FLOOR_SIDE' + @conditions.meta_cond(priority=-1000, only_once=False) def find_indicator_panels(inst): @@ -111,8 +114,6 @@ def res_cutout_tile(inst, res): 'beam_skin': res['squarebeamsSkin', '0'], - 'floor_edge': res['floorEdgeInst', ''], - 'quad_floor': res['FloorSize', '4x4'].casefold() == '2x2', 'quad_ceil': res['CeilingSize', '4x4'].casefold() == '2x2', } @@ -218,15 +219,15 @@ def res_cutout_tile(inst, res): # Mark borders we need to fill in, and the angle (for func_instance) for x in range(int(box_min.x), int(box_max.x)+1, 128): # North, South - floor_edges.append((Vec(x, box_max.y + 64, z-64), '0 270 0')) - floor_edges.append((Vec(x, box_min.y - 64, z-64), '0 90 0')) + floor_edges.append((Vec(x, box_max.y + 64, z-64), 270)) + floor_edges.append((Vec(x, box_min.y - 64, z-64), 90)) for y in range(int(box_min.y), int(box_max.y)+1, 128): # East, West - floor_edges.append((Vec(box_max.x + 64, y, z-64), '0 180 0')) - floor_edges.append((Vec(box_min.x - 64, y, z-64), '0 0 0')) + floor_edges.append((Vec(box_max.x + 64, y, z-64), 180)) + floor_edges.append((Vec(box_min.x - 64, y, z-64), 0)) - add_floor_sides(floor_edges, MATS['squarebeams'], SETTINGS['floor_edge']) + add_floor_sides(floor_edges, MATS['squarebeams']) reallocate_overlays(overlay_ids) @@ -488,7 +489,7 @@ def reallocate_overlays(mapping): overlay['sides'] = ' '.join(sides) -def add_floor_sides(locs, tex, file): +def add_floor_sides(locs, tex): """We need to replace nodraw textures around the outside of the holes. This requires looping through all faces, since these will have been @@ -515,15 +516,24 @@ def add_floor_sides(locs, tex, file): vbsp.IGNORED_FACES.add(face) # Look for the ones without a texture - these are open to the void and - # need to be sealed. We use an instance to allow chamfering the edges + # need to be sealed. The template chamfers the edges # to prevent showing void at outside corners. - for loc, angles in locs: + for loc, rot in locs: if added_locations[loc.as_tuple()]: continue - conditions.VMF.create_ent( - classname='func_instance', - file=file, - origin=loc.join(' '), - angles=angles, + world, detail = conditions.import_template( + SIDE_BRUSH_TEMPLATE, + origin=loc, + angles=Vec(0, rot, 0), + force_type=conditions.TEMP_TYPES.world, + ) + conditions.retexture_template( + world, + detail, + loc, + # Switch to use the configured squarebeams texture + replace_tex={ + 'anim_wp/framework/squarebeams': random.choice(tex), + } ) \ No newline at end of file From 5f40b208472aeecdce44748b097f3a97df6a08ad Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Thu, 19 Nov 2015 13:14:22 +1000 Subject: [PATCH 72/91] Rewrite clumping algorithm This way it can work on non-axis-aligned brushes, as well as floors and ceilings --- src/vbsp.py | 308 ++++++++++++++++++++++++++-------------------------- 1 file changed, 156 insertions(+), 152 deletions(-) diff --git a/src/vbsp.py b/src/vbsp.py index 3890e72d6..8f6d9d247 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -5,7 +5,7 @@ import shutil import random from enum import Enum -from collections import defaultdict +from collections import defaultdict, namedtuple from decimal import Decimal from property_parser import Property @@ -190,9 +190,14 @@ def __str__(self): "grating_inst": "NONE", "clump_wall_tex": "0", # Use the clumping wall algorithm + "clump_ceil": "0", # Use if for ceilings? + "clump_floor": "0", # Use it for floors? "clump_size": "4", # The maximum length of a clump "clump_width": "2", # The width of a clump "clump_number": "6", # The number of clumps created + + "draw_clumps": "0", # Debug, add skip brushes for clumps + # Default to the origin of the elevator instance - that's likely to # be enclosed "music_location_sp": "-2000 2000 0", @@ -248,7 +253,7 @@ def __str__(self): "scanline": "0", } -BEE2_config = None # ConfigFile +BEE2_config = None # ConfigFile GAME_MODE = 'ERR' IS_PREVIEW = 'ERR' @@ -299,27 +304,8 @@ def alter_mat(face, seed=None, texture_lock=True): face.mat = get_tex(TEX_VALVE[mat]) return True elif mat in BLACK_PAN or mat in WHITE_PAN: - surf_type = 'white' if mat in WHITE_PAN else 'black' orient = get_face_orient(face) - # We need to handle specially the 4x4 and 2x4 variants. - # These are used in the embedface brushes, so they should - # remain having small tile size. Wall textures have 4x4 and 2x2, - # but floor/ceilings only have 4x4 sizes (since they usually - # just stay the same). - if orient == ORIENT.wall: - if (mat == 'metal/black_wall_metal_002b' or - mat == 'tile/white_wall_tile003f'): - orient = '4x4' - elif (mat == 'metal/black_wall_metal_002a' or - mat == 'tile/white_wall_tile003c'): - orient = '2x2' - else: - orient = 'wall' - elif orient == ORIENT.floor: - orient = 'floor' - elif orient == ORIENT.ceiling: - orient = 'ceiling' - face.mat = get_tex(surf_type + '.' + orient) + face.mat = get_tex(get_tile_type(mat, orient)) if not texture_lock: face.offset = 0 @@ -330,6 +316,30 @@ def alter_mat(face, seed=None, texture_lock=True): else: return False + +def get_tile_type(mat, orient): + """Get the texture command for a texture.""" + surf_type = 'white' if mat in WHITE_PAN else 'black' + # We need to handle specially the 4x4 and 2x4 variants. + # These are used in the embedface brushes, so they should + # remain having small tile size. Wall textures have 4x4 and 2x2, + # but floor/ceilings only have 4x4 sizes (since they usually + # just stay the same). + if orient == ORIENT.wall: + if (mat == 'metal/black_wall_metal_002b' or + mat == 'tile/white_wall_tile003f'): + orient = '4x4' + elif (mat == 'metal/black_wall_metal_002a' or + mat == 'tile/white_wall_tile003c'): + orient = '2x2' + else: + orient = 'wall' + elif orient == ORIENT.floor: + orient = 'floor' + elif orient == ORIENT.ceiling: + orient = 'ceiling' + return surf_type + '.' + orient + ################## # MAIN functions # ################## @@ -1353,12 +1363,6 @@ def change_brush(): ) mist_solids = set() - # Check the clump algorithm has all its arguements - can_clump = (get_bool_opt("clump_wall_tex") and - get_opt("clump_size").isnumeric() and - get_opt("clump_width").isnumeric() and - get_opt("clump_number").isnumeric()) - if utils.conv_bool(get_opt('remove_pedestal_plat')): # Remove the pedestal platforms for ent in VMF.by_class['func_detail']: @@ -1426,12 +1430,23 @@ def change_brush(): add_goo_mist(mist_solids) utils.con_log('Done!') - if can_clump: + if can_clump(): clump_walls() else: random_walls() +def can_clump(): + """Check the clump algorithm has all its arguments.""" + if not get_bool_opt("clump_wall_tex"): + return False + if not get_opt("clump_size").isnumeric(): + return False + if not get_opt("clump_width").isnumeric(): + return False + return get_opt("clump_number").isnumeric() + + def switch_glass_inst(origin, new_file): """Find the glass instance placed in the specified location. @@ -1517,7 +1532,6 @@ def get_grid_sizes(face: VLib.Side): def random_walls(): """The original wall style, with completely randomised walls.""" - scale_walls = get_bool_opt("random_blackwall_scale") rotate_edge = get_bool_opt('rotate_edge') texture_lock = get_bool_opt('tile_texture_lock', True) edge_off = get_bool_opt('reset_edge_off', False) @@ -1531,20 +1545,16 @@ def random_walls(): if face.mat.casefold() == 'anim_wp/framework/squarebeams': fix_squarebeams(face, rotate_edge, edge_off, edge_scale) - orient = get_face_orient(face) - # Only modify black walls and ceilings - if (scale_walls and - face.mat.casefold() in BLACK_PAN and - orient is not ORIENT.floor): - - random.seed(face_seed(face) + '_SCALE_VAL') - # randomly scale textures to achieve the P1 multi-sized - # black tile look without custom textues - scale = random.choice(get_grid_sizes(face)) - face.scale = scale alter_mat(face, face_seed(face), texture_lock) +Clump = namedtuple('Clump', [ + 'min_pos', + 'max_pos', + 'tex', +]) + + def clump_walls(): """A wall style where textures are used in small groups near each other. @@ -1556,132 +1566,126 @@ def clump_walls(): # These are 2x2x4 maximum rectangular areas (configurable), which all get # the same texture. We don't overwrite previously-set ones though. # After that, we fill in any unset textures with the white/black_gap ones. - # This makes it look like those areas were patched up - # The floor and ceiling are made normally. - - # Additionally, we are able to nodraw all attached faces. - walls = {} - - # we keep a list for the others, so we can nodraw them if needed - others = {} + # This makes it look like those areas were patched up. texture_lock = get_bool_opt('tile_texture_lock', True) rotate_edge = get_bool_opt('rotate_edge') edge_off = get_bool_opt('reset_edge_off', False) edge_scale = utils.conv_float(get_opt('edge_scale'), 0.15) - for solid in VMF.iter_wbrushes(world=True, detail=True): - # first build a dict of all textures and their locations... - for face in solid: - if face in IGNORED_FACES: - continue + # Possible locations for clumps - every face origin, not including + # ignored faces or nodraw + possible_locs = [ + face.get_origin() + for face in + VMF.iter_wfaces(world=True, detail=True) + if face not in IGNORED_FACES + if face.mat.casefold() != 'tools/toolsnodraw' + ] - mat = face.mat.casefold() - if mat in ( - 'glass/glasswindow007a_less_shiny', - 'metal/metalgrate018', - 'anim_wp/framework/squarebeams', - 'tools/toolsnodraw', - 'anim_wp/framework/backpanels_cheap' - ): - # These textures aren't wall textures, and usually never - # use random textures. Don't add them here. They also aren't - # on grid. - alter_mat(face) - if mat == 'anim_wp/framework/squarebeams': - fix_squarebeams(face, rotate_edge, edge_off, edge_scale) - continue + clump_size = utils.conv_int(get_opt("clump_size"), 4) + clump_wid = utils.conv_int(get_opt("clump_width"), 2) - if face.mat in GOO_TEX: - # For goo textures, don't add them to the dicts - # or floors will be nodrawed. - alter_mat(face) - break + clump_numb = len(possible_locs) // clump_size + clump_numb *= utils.conv_int(get_opt("clump_number"), 6) + + # Also clump ceilings or floors? + clump_ceil = get_bool_opt('clump_ceil') + clump_floor = get_bool_opt('clump_floor') - origin = face.get_origin().as_tuple() - orient = get_face_orient(face) - if orient is ORIENT.wall: - # placeholder to indicate these can be replaced. - if mat in WHITE_PAN: - face.mat = "WHITE" - elif mat in BLACK_PAN: - face.mat = "BLACK" - if origin in walls: - # The only time two textures will be in the same - # place is if they are covering each other - - # nodraw them both and ignore them - face.mat = "tools/toolsnodraw" - walls[origin].mat = "tools/toolsnodraw" - del walls[origin] - else: - walls[origin] = face - else: - if origin in others: - # The only time two textures will be in the same - # place is if they are covering each other - delete - # them both. - face.mat = "tools/toolsnodraw" - others[origin].mat = "tools/toolsnodraw" - del others[origin] - else: - others[origin] = face - alter_mat(face, face_seed(face), texture_lock) - - todo_walls = len(walls) # number of walls un-edited - clump_size = int(get_opt("clump_size")) - clump_wid = int(get_opt("clump_width")) - clump_numb = (todo_walls // clump_size) * int(get_opt("clump_number")) - wall_pos = sorted(list(walls.keys())) random.seed(MAP_SEED) + + clumps = [] + for _ in range(clump_numb): - pos = random.choice(wall_pos) - wall_type = walls[pos].mat - pos = Vec(pos) // 128 * 128 - ':type pos: Vec' - state = random.getstate() # keep using the map_seed for the clumps - if wall_type == "WHITE" or wall_type == "BLACK": - random.seed(pos.as_tuple()) - pos_min = Vec() - pos_max = Vec() - # these are long strips extended in one direction - direction = random.randint(0, 2) - for i in range(3): - if i == direction: - dist = clump_size - else: - dist = clump_wid - pos_min[i] = int( - pos[i] - random.randint(0, dist) * 128) - pos_max[i] = int( - pos[i] + random.randint(0, dist) * 128) - - tex = get_tex(wall_type.lower() + '.wall') - # Loop though all these grid points, and set to the given - # texture if they have the same wall type - for pos, side in walls.items(): - if pos_min <= Vec(pos) <= pos_max and side.mat == wall_type: - side.mat = tex - if not texture_lock: - side.offset = 0 - # Return to the map_seed state. - random.setstate(state) - - for pos, face in walls.items(): - random.seed(pos) - # We missed these ones! - if face.mat == "WHITE": + # Picking out of the map origins helps ensure at least 1 texture is + # modded by a clump + pos = random.choice(possible_locs) // 128 * 128 # type: Vec + + pos_min = Vec() + pos_max = Vec() + # Clumps are long strips mainly extended in one direction + # In the other directions extend by 'width'. It can point any axis. + direction = random.choice('xyz') + for axis in 'xyz': + if axis == direction: + dist = clump_size + else: + dist = clump_wid + pos_min[axis] = pos[axis] - random.randint(0, dist) * 128 + pos_max[axis] = pos[axis] + random.randint(0, dist) * 128 + cur_state = random.getstate() + random.seed('CLUMP_TEX_' + pos_min.join() + '_' + pos_max.join(' ')) + clumps.append(Clump( + pos_min, + pos_max, + # For each clump, every tile gets the same texture! + { + (color + '.' + size): get_tex(color + '.' + size) + for color in ('white', 'black') + for size in ('wall', 'floor', 'ceiling', '2x2', '4x4') + } + )) + random.setstate(cur_state) + + if get_bool_opt('draw_clumps'): + for clump in clumps: + VMF.add_brush(VMF.make_prism( + clump.min_pos, + clump.max_pos, + mat='tools/toolsskip', + ).solid) + + # Now modify each texture! + for face in VMF.iter_wfaces(world=True, detail=True): + if face in IGNORED_FACES: + continue + + mat = face.mat.casefold() + + if mat == 'anim_wp/framework/squarebeams': + # Handle squarebeam transformations + alter_mat(face, face_seed(face), texture_lock) + fix_squarebeams(face, rotate_edge, edge_off, edge_scale) + continue + + if mat not in WHITE_PAN or mat not in BLACK_PAN: + # Don't clump non-wall textures + alter_mat(face, face_seed(face), texture_lock) + + orient = get_face_orient(face) + + if ( + (orient is ORIENT.floor and not clump_floor) or + (orient is ORIENT.ceiling and not clump_ceil)): + # Don't clump if configured not to for this orientation + alter_mat(face, face_seed(face), texture_lock) + continue + + mat = face.mat.casefold() + + # Clump the texture! + origin = face.get_origin() + for clump in clumps: + if not (clump.min_pos <= origin <= clump.max_pos): + continue + face.mat = clump.tex[get_tile_type(mat, orient)] + else: + # Not in a clump! # Allow using special textures for these, to fill in gaps. - if not get_tex("special.white_gap") == "": + orig_mat = mat + if mat in WHITE_PAN: face.mat = get_tex("special.white_gap") - else: - face.mat = get_tex("white.wall") - elif face.mat == "BLACK": - if not get_tex("special.black_gap") == "": + if not face.mat: + face.mat = orig_mat + alter_mat(face, texture_lock=texture_lock) + if mat in WHITE_PAN: face.mat = get_tex("special.black_gap") + if not face.mat: + face.mat = orig_mat + alter_mat(face, texture_lock=texture_lock) else: - face.mat = get_tex("black.wall") - else: - alter_mat(face, seed=pos, texture_lock=texture_lock) + alter_mat(face, texture_lock=texture_lock) def get_face_orient(face): From f6509814aea7aae8202bdaf4abf33b149b3c909c Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Thu, 19 Nov 2015 13:52:10 +1000 Subject: [PATCH 73/91] Make clumping algorithm work --- src/vbsp.py | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/vbsp.py b/src/vbsp.py index 8f6d9d247..1187d6861 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -196,8 +196,6 @@ def __str__(self): "clump_width": "2", # The width of a clump "clump_number": "6", # The number of clumps created - "draw_clumps": "0", # Debug, add skip brushes for clumps - # Default to the origin of the elevator instance - that's likely to # be enclosed "music_location_sp": "-2000 2000 0", @@ -1580,19 +1578,21 @@ def clump_walls(): for face in VMF.iter_wfaces(world=True, detail=True) if face not in IGNORED_FACES - if face.mat.casefold() != 'tools/toolsnodraw' + if face.mat.casefold() in WHITE_PAN or face.mat.casefold() in BLACK_PAN ] clump_size = utils.conv_int(get_opt("clump_size"), 4) clump_wid = utils.conv_int(get_opt("clump_width"), 2) - clump_numb = len(possible_locs) // clump_size + clump_numb = len(possible_locs) // (clump_size * clump_wid * clump_wid) clump_numb *= utils.conv_int(get_opt("clump_number"), 6) # Also clump ceilings or floors? clump_ceil = get_bool_opt('clump_ceil') clump_floor = get_bool_opt('clump_floor') + utils.con_log('Clumping: {} clumps'.format(clump_numb)) + random.seed(MAP_SEED) clumps = [] @@ -1600,7 +1600,7 @@ def clump_walls(): for _ in range(clump_numb): # Picking out of the map origins helps ensure at least 1 texture is # modded by a clump - pos = random.choice(possible_locs) // 128 * 128 # type: Vec + pos = random.choice(possible_locs) // 128 * 128 # type: Vec pos_min = Vec() pos_max = Vec() @@ -1628,14 +1628,6 @@ def clump_walls(): )) random.setstate(cur_state) - if get_bool_opt('draw_clumps'): - for clump in clumps: - VMF.add_brush(VMF.make_prism( - clump.min_pos, - clump.max_pos, - mat='tools/toolsskip', - ).solid) - # Now modify each texture! for face in VMF.iter_wfaces(world=True, detail=True): if face in IGNORED_FACES: @@ -1649,9 +1641,10 @@ def clump_walls(): fix_squarebeams(face, rotate_edge, edge_off, edge_scale) continue - if mat not in WHITE_PAN or mat not in BLACK_PAN: + if mat not in WHITE_PAN and mat not in BLACK_PAN: # Don't clump non-wall textures alter_mat(face, face_seed(face), texture_lock) + continue orient = get_face_orient(face) @@ -1662,14 +1655,12 @@ def clump_walls(): alter_mat(face, face_seed(face), texture_lock) continue - mat = face.mat.casefold() - # Clump the texture! origin = face.get_origin() for clump in clumps: - if not (clump.min_pos <= origin <= clump.max_pos): - continue - face.mat = clump.tex[get_tile_type(mat, orient)] + if clump.min_pos <= origin <= clump.max_pos: + face.mat = clump.tex[get_tile_type(mat, orient)] + break else: # Not in a clump! # Allow using special textures for these, to fill in gaps. @@ -1679,7 +1670,7 @@ def clump_walls(): if not face.mat: face.mat = orig_mat alter_mat(face, texture_lock=texture_lock) - if mat in WHITE_PAN: + elif mat in BLACK_PAN: face.mat = get_tex("special.black_gap") if not face.mat: face.mat = orig_mat From cbd5ef9e2d1028bd6a0cc72a0ecd1ca972ad9c8b Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Thu, 19 Nov 2015 14:39:06 +1000 Subject: [PATCH 74/91] Use clumping for template brushes --- src/conditions.py | 27 ++++++++++++++++++++++++--- src/vbsp.py | 8 +++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index 7e5b012ca..b191e654a 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -766,6 +766,8 @@ def retexture_template( # sin(40) = ~0.707 floor_tolerance = 0.8 + can_clump = vbsp.can_clump() + for brush in all_brushes: for face in brush: folded_mat = face.mat.casefold() @@ -843,9 +845,28 @@ def retexture_template( if norm.z < -floor_tolerance: grid_size = 'floor' - face.mat = vbsp.get_tex( - '{!s}.{!s}'.format(tex_colour, grid_size) - ) + if can_clump: + # For the clumping algorithm, set to Valve PeTI and let + # clumping handle retexturing. + vbsp.IGNORED_FACES.remove(face) + if tex_colour is MAT_TYPES.white: + if grid_size == '4x4': + face.mat = 'tile/white_wall_tile003f' + elif grid_size == '2x2': + face.mat = 'tile/white_wall_tile003c' + else: + face.mat = 'tile/white_wall_tile003h' + elif tex_colour is MAT_TYPES.black: + if grid_size == '4x4': + face.mat = 'metal/black_wall_metal_002b' + elif grid_size == '2x2': + face.mat = 'metal/black_wall_metal_002a' + else: + face.mat = 'metal/black_wall_metal_002e' + else: + face.mat = vbsp.get_tex( + '{!s}.{!s}'.format(tex_colour, grid_size) + ) @make_flag('debug') diff --git a/src/vbsp.py b/src/vbsp.py index 1187d6861..6adbabdd8 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -1682,10 +1682,12 @@ def clump_walls(): def get_face_orient(face): """Determine the orientation of an on-grid face.""" norm = face.normal() - if norm == (0, 0, -1): + # Even if not axis-aligned, make mostly-flat surfaces + # floor/ceiling (+-40 degrees) + # sin(40) = ~0.707 + if norm.z < -0.8: return ORIENT.floor - - if norm == (0, 0, 1): + if norm.z > 0.8: return ORIENT.ceiling return ORIENT.wall From a612c426b4feca3a5fcd1058d8eaba7319509b41 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Fri, 20 Nov 2015 15:30:38 +1000 Subject: [PATCH 75/91] Add ability to pack files with the signInst option --- src/vbsp.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vbsp.py b/src/vbsp.py index 6adbabdd8..80fc9a2ce 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -180,6 +180,7 @@ def __str__(self): "staticPan": "NONE", # folder for static panels "signInst": "NONE", # adds this instance on all the signs. "signSize": "32", # Allow resizing the sign overlays + "signPack": "", # Packlist to use when sign inst is added "glass_scale": "0.15", # Scale of glass texture "grating_scale": "0.15", # Scale of grating texture @@ -1743,6 +1744,8 @@ def change_overlays(): if sign_inst == "NONE": sign_inst = None + sign_inst_pack = get_opt('signPack') + ant_str = settings['textures']['overlay.antline'] ant_str_floor = settings['textures']['overlay.antlinefloor'] ant_corn = settings['textures']['overlay.antlinecorner'] @@ -1775,6 +1778,8 @@ def change_overlays(): angles=over['angles', '0 0 0'], file=sign_inst, ) + if sign_inst_pack: + TO_PACK.add(sign_inst_pack.casefold()) new_inst.fixup['mat'] = sign_type.replace('overlay.', '') over['material'] = get_tex(sign_type) From adb25b1ae670e3f7ee898407dc5305dbdfdb47b5 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 21 Nov 2015 09:13:50 +1000 Subject: [PATCH 76/91] Add versions to BEE2 title bars --- src/backup.py | 5 +++++ src/compile_BEE2.py | 4 ++++ src/compile_vbsp_vrad.py | 18 ++++++++++++------ src/utils.py | 13 +++++++++++-- src/vbsp.py | 2 +- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/backup.py b/src/backup.py index 95da2814b..673489b76 100644 --- a/src/backup.py +++ b/src/backup.py @@ -704,6 +704,10 @@ def init_application(): """Initialise the standalone application.""" global window window = TK_ROOT + TK_ROOT.title( + 'BEEMOD {} - Backup / Restore Puzzles'.format(utils.BEE_VERSION) + ) + init() UI['bar'] = bar = tk.Menu(TK_ROOT) @@ -815,6 +819,7 @@ def init_toplevel(): window = tk.Toplevel(TK_ROOT) window.transient(TK_ROOT) window.withdraw() + window.title('Backup/Restore Puzzles') def quit_command(): from BEE2_config import GEN_OPTS diff --git a/src/compile_BEE2.py b/src/compile_BEE2.py index ab4e88344..9d253cc79 100644 --- a/src/compile_BEE2.py +++ b/src/compile_BEE2.py @@ -89,6 +89,8 @@ else: base = None +bee_version = input('BEE2 Version: ') + setup( name='BEE2', version='2.4', @@ -97,6 +99,8 @@ 'build_exe': { 'build_exe': '../build_BEE2/bin', 'excludes': EXCLUDES, + # These values are added to the generated BUILD_CONSTANTS module. + 'constants': 'BEE_VERSION=' + repr(bee_version), }, }, executables=[ diff --git a/src/compile_vbsp_vrad.py b/src/compile_vbsp_vrad.py index 902287b7a..6b83ec8c3 100644 --- a/src/compile_vbsp_vrad.py +++ b/src/compile_vbsp_vrad.py @@ -10,6 +10,8 @@ suffix = '_osx' elif utils.LINUX: suffix = '_linux' +else: + suffix = '' # Unneeded packages that cx_freeze detects: EXCLUDES = [ @@ -18,15 +20,19 @@ 'doctest', # Used in __main__ of decimal and heapq 'dis', # From inspect, not needed ] + +bee_version = input('BEE2 Version: ') + setup( name='VBSP_VRAD', version='0.1', options={ - 'build_exe': - { - 'build_exe': '../compiler', - 'excludes': EXCLUDES, - } + 'build_exe': { + 'build_exe': '../compiler', + 'excludes': EXCLUDES, + # These values are added to the generated BUILD_CONSTANTS module. + 'constants': 'BEE_VERSION=' + repr(bee_version), + }, }, description='BEE2 VBSP and VRAD compilation hooks, ' 'for modifying PeTI maps during compilation.', @@ -42,6 +48,6 @@ base='Console', icon=ico_path, targetName='vrad' + suffix, - ) + ), ] ) \ No newline at end of file diff --git a/src/utils.py b/src/utils.py index 0b6e20a0e..1e666f399 100644 --- a/src/utils.py +++ b/src/utils.py @@ -12,13 +12,22 @@ SupportsFloat, Iterator, ) +try: + # This module is generated when cx_freeze compiles the app. + from BUILD_CONSTANTS import BEE_VERSION +except ImportError: + # We're running from source! + BEE_VERSION = "(dev)" + FROZEN = False +else: + FROZEN = True WIN = platform.startswith('win') MAC = platform.startswith('darwin') LINUX = platform.startswith('linux') -BEE_VERSION = "2.4" - +# App IDs for various games. Used to determine which game we're modding +# and activate special support for them STEAM_IDS = { 'PORTAL2': '620', diff --git a/src/vbsp.py b/src/vbsp.py index 80fc9a2ce..ab159e2b8 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -2299,7 +2299,7 @@ def main(): """ global MAP_SEED, IS_PREVIEW, GAME_MODE - utils.con_log("BEE2 VBSP hook initiallised.") + utils.con_log("BEE{} VBSP hook initiallised.".format(utils.BEE_VERSION)) args = " ".join(sys.argv) new_args = sys.argv[1:] From e6984e6cfd673987c29e8f8c912c2e7ad7c2a93b Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 21 Nov 2015 10:07:27 +1000 Subject: [PATCH 77/91] Use a singleton to indicate a result is exhausted, instead of 'True' This makes it more clear what the value does. --- src/conditions.py | 26 +++++++++++++++----------- src/vbsp.py | 2 ++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/conditions.py b/src/conditions.py index f003e29fd..ae5a05b99 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -179,6 +179,10 @@ class EndCondition(Exception): """Raised to skip the condition entirely, from the EndCond result.""" pass +# Flag to indicate a result doesn't need to be exectuted anymore, +# and can be cleaned up - adding a global instance, for example. +RES_EXHAUSTED = object() + class Condition: __slots__ = ['flags', 'results', 'else_results', 'priority'] @@ -287,7 +291,7 @@ def test(self, inst): results = self.results if success else self.else_results for res in results[:]: should_del = self.test_result(inst, res) - if should_del is True: + if should_del is RES_EXHAUSTED: results.remove(res) @@ -1242,7 +1246,7 @@ def res_set_style_var(_, res): STYLE_VARS[opt.value.casefold()] = True elif opt.name == 'setfalse': STYLE_VARS[opt.value.casefold()] = False - return True # Remove this result + return RES_EXHAUSTED @make_result('has') @@ -1257,7 +1261,7 @@ def res_set_voice_attr(_, res): VOICE_ATTR[opt.name] = True else: VOICE_ATTR[res.value.casefold()] = 1 - return True # Remove this result + return RES_EXHAUSTED @make_result('setOption') @@ -1269,7 +1273,7 @@ def res_set_option(_, res): for opt in res.value: if opt.name in OPTIONS: OPTIONS[opt.name] = opt.value - return True # Remove this result + return RES_EXHAUSTED @make_result('setKey') @@ -1476,7 +1480,7 @@ def res_add_global_inst(_, res): new_inst['targetname'] = "inst_" new_inst.make_unique() VMF.add_ent(new_inst) - return True # Remove this result + return RES_EXHAUSTED @make_result('addOverlay', 'overlayinst') @@ -2292,7 +2296,7 @@ def res_make_catwalk(_, res): ) utils.con_log('Finished catwalk generation!') - return True # Don't run this again + return RES_EXHAUSTED @make_result_setup('staticPiston') @@ -2500,7 +2504,7 @@ def res_track_plat(_, res): if plat_var != '': # Skip the '_mirrored' section if needed plat_inst.fixup[plat_var] = track_facing[:5].lower() - return True # Only run once! + return RES_EXHAUSTED def track_scan( @@ -3044,7 +3048,7 @@ def res_unst_scaffold(_, res): # The instance types we're modifying if res.value not in SCAFFOLD_CONFIGS: # We've already executed this config group - return True + return RES_EXHAUSTED utils.con_log( 'Running Scaffold Generator (' + res.value + ')...' @@ -3235,7 +3239,7 @@ def res_unst_scaffold(_, res): ent['file'] = new_file utils.con_log('Finished Scaffold generation!') - return True # Don't run this again + return RES_EXHAUSTED @make_result('RandomNum') @@ -3343,7 +3347,7 @@ def res_goo_debris(_, res): angles='0 {} 0'.format(random.randrange(0, 3600)/10) ) - return True # Only run once! + return RES_EXHAUSTED # A mapping of fizzler targetnames to the base instance tag_fizzlers = {} @@ -3356,7 +3360,7 @@ def res_find_potential_tag_fizzlers(inst): This is used for Aperture Tag paint fizzlers. """ if OPTIONS['game_id'] != utils.STEAM_IDS['TAG']: - return True # We don't need to bother running this check + return RES_EXHAUSTED if inst['file'].casefold() in resolve_inst(''): # The key list in the dict will be a set of all fizzler items! diff --git a/src/vbsp.py b/src/vbsp.py index ab159e2b8..6fbee7882 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -2138,6 +2138,8 @@ def packlist_cond(_, res): """Add the files in the given packlist to the map.""" TO_PACK.add(res.value.casefold()) + return conditions.RES_EXHAUSTED + def make_packlist(map_path): """Write the list of files that VRAD should pack.""" From 6d9a376e54b105b716270b555e8a1b937d6a9f80 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 21 Nov 2015 11:39:35 +1000 Subject: [PATCH 78/91] Rename backups instead of copy + delete --- src/backup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backup.py b/src/backup.py index 673489b76..b2621e669 100644 --- a/src/backup.py +++ b/src/backup.py @@ -355,9 +355,11 @@ def auto_backup(game: 'gameMan.Game', loader: LoadScreen): old_name = os.path.join(backup_dir, old_name) new_name = os.path.join(backup_dir, new_name) try: - # Overwrites! - shutil.copyfile(old_name, new_name) - os.remove(old_name) + os.remove(new_name) + except FileNotFoundError: + pass # We're overwriting this anyway + try: + os.rename(old_name, new_name) except FileNotFoundError: pass From ec7df93a552bac42c32b2666f987e320a9e16520 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 21 Nov 2015 11:40:06 +1000 Subject: [PATCH 79/91] Handle getting permission errors when writing compiler --- src/UI.py | 7 +++++-- src/gameMan.py | 25 +++++++++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/UI.py b/src/UI.py index e2ab0f0ce..5f75b5afe 100644 --- a/src/UI.py +++ b/src/UI.py @@ -751,7 +751,7 @@ def export_editoritems(_=None): for var in StyleVarPane.styleOptions: style_vars[var.id] = style_vals[var.id].get() == 1 - gameMan.selected_game.export( + sucess = gameMan.selected_game.export( chosen_style, item_list, music=musics.get(music_win.chosen_id, None), @@ -768,10 +768,13 @@ def export_editoritems(_=None): ) ) + if not sucess: + return + messagebox.showinfo( 'BEEMOD2', message='Selected Items and Style successfully exported!', - ) + ) for pal in palettes[:]: if pal.name == '': diff --git a/src/gameMan.py b/src/gameMan.py index e26d5e2be..3f25d7401 100644 --- a/src/gameMan.py +++ b/src/gameMan.py @@ -566,10 +566,26 @@ def export( print('Copying Custom Compiler!') for file in os.listdir('../compiler'): print('\t* compiler/{0} -> bin/{0}'.format(file)) - shutil.copy( - os.path.join('../compiler', file), - self.abs_path('bin/') - ) + try: + shutil.copy( + os.path.join('../compiler', file), + self.abs_path('bin/') + ) + except PermissionError: + # We might not have permissions, if the compiler is currently + # running. + export_screen.grab_release() + export_screen.reset() + messagebox.showerror( + title='BEE2 - Export Failed!', + message='Copying compiler file {file} failed.' + 'Ensure the {game} is not running.'.format( + file=file, + game=self.name, + ), + master=TK_ROOT, + ) + return False export_screen.step('COMP') if should_refresh: @@ -578,6 +594,7 @@ def export( export_screen.grab_release() export_screen.reset() # Hide loading screen, we're done + return True def find_steam_info(game_dir): From ac14a21bd5b18db1f2650ee7764a40584c42a62f Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 21 Nov 2015 12:25:22 +1000 Subject: [PATCH 80/91] Add some logic for Cave portrait skin switching This way styles can incorperate Cave portraits, when he's the narrator. --- src/conditions.py | 26 ++++++++++++++++++++++++++ src/gameMan.py | 5 +++++ src/packageLoader.py | 9 +++++++++ src/vbsp.py | 1 + 4 files changed, 41 insertions(+) diff --git a/src/conditions.py b/src/conditions.py index ae5a05b99..b0f3d535f 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -1078,6 +1078,16 @@ def flag_voice_char(_, flag): return False +@make_flag('HasCavePortrait') +def res_cave_portrait(inst, res): + """Checks to see if the Cave Portrait option is set for the given + + skin pack. + """ + import vbsp + return vbsp.get_opt('cave_port_skin') != '' + + @make_flag('ifOption') def flag_option(_, flag): bits = flag.value.split(' ', 1) @@ -1524,6 +1534,22 @@ def res_add_overlay_inst(inst, res): overlay_inst['origin'] = ( offset + Vec.from_str(inst['origin']) ).join(' ') + return overlay_inst + + +@make_result('addCavePortrait') +def res_cave_portrait(inst, res): + """A variant of AddOverlay for adding Cave Portraits. + + If the set quote pack is not Cave Johnson, this does nothing. + Otherwise, this overlays an instance, setting the $skin variable + appropriately. + """ + import vbsp + skin = vbsp.get_opt('cave_port_skin') + if skin != '': + new_inst = res_add_overlay_inst(inst, res) + new_inst.fixup['$skin'] = skin @make_result('OffsetInst', 'offsetinstance') diff --git a/src/gameMan.py b/src/gameMan.py index 3f25d7401..b3d21200b 100644 --- a/src/gameMan.py +++ b/src/gameMan.py @@ -412,6 +412,11 @@ def export( ('Options', 'voice_char'), ','.join(voice.chars) ) + if voice.cave_skin is not None: + vbsp_config.set_key( + ('Options', 'cave_port_skin'), + voice.cave_skin, + ) vbsp_config.set_key( ('Options', 'BEE2_loc'), diff --git a/src/packageLoader.py b/src/packageLoader.py index 4eb81fe73..148f515fd 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -693,9 +693,11 @@ def __init__( selitem_data: 'SelitemData', config, chars=None, + skin=None, ): self.id = quote_id self.selitem_data = selitem_data + self.cave_skin = skin self.config = config self.chars = chars or ['??'] @@ -710,6 +712,10 @@ def parse(cls, data): if char.strip() } + # For Cave Johnson voicelines, this indicates what skin to use on the + # portrait. + port_skin = utils.conv_int(data.info['caveSkin', None], None) + config = get_config( data.info, data.zip_file, @@ -723,6 +729,7 @@ def parse(cls, data): selitem_data, config, chars=chars, + skin=port_skin, ) def add_over(self, override: 'QuotePack'): @@ -733,6 +740,8 @@ def add_over(self, override: 'QuotePack'): 'quotes_sp', 'quotes_coop', ) + if self.cave_skin is None: + self.cave_skin = override.cave_skin def __repr__(self): return '' diff --git a/src/vbsp.py b/src/vbsp.py index 6fbee7882..c45e66134 100644 --- a/src/vbsp.py +++ b/src/vbsp.py @@ -223,6 +223,7 @@ def __str__(self): "elev_vert": "", # The vertical elevator video to use "voice_id": "", # The voice pack which was selected "voice_char": "", # Characters in the pack + "cave_port_skin": "", # If a Cave map, indicate which portrait to use. } # angles needed to ensure fizzlers are not upside-down From e46b227e464077eaa005fa2eb26a3d1263ae95ec Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sat, 21 Nov 2015 13:42:36 +1000 Subject: [PATCH 81/91] Fix early abort for catwalks --- src/conditions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/conditions.py b/src/conditions.py index b0f3d535f..046211f96 100644 --- a/src/conditions.py +++ b/src/conditions.py @@ -2213,7 +2213,7 @@ def res_make_catwalk(_, res): markers[inst['targetname']] = inst if not markers: - return True # No catwalks! + return RES_EXHAUSTED utils.con_log('Conn:', connections) utils.con_log('Markers:', markers) From 3ebeecd7281fb8e63e5a186d308cfa47a0015ec8 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 10:45:19 +1000 Subject: [PATCH 82/91] Indicate that the getitem default can be anything --- src/property_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/property_parser.py b/src/property_parser.py index 98ac67f42..e23998945 100644 --- a/src/property_parser.py +++ b/src/property_parser.py @@ -7,7 +7,7 @@ import utils from typing import ( - Optional, Union, + Optional, Union, Any, Dict, List, Tuple, Iterator, ) @@ -418,7 +418,7 @@ def __getitem__( str, int, slice, - Tuple[Union[str, int, slice], _Prop_Value], + Tuple[Union[str, int, slice], Union[_Prop_Value, Any]], ], ): """Allow indexing the children directly. From ab4521c276c6b8a91a91f41eb8ece54ea010d5c7 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 11:13:54 +1000 Subject: [PATCH 83/91] Add ability to enable/disable packages Replace PackageData with a full class - Move some of the package logic into the class - Add packages.cfg file, which enables/disables specific packages --- .gitignore | 8 +--- src/packageLoader.py | 102 ++++++++++++++++++++++++++++++------------- src/packageMan.py | 13 ++++++ 3 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 src/packageMan.py diff --git a/.gitignore b/.gitignore index c0c67cd9f..78754e3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -57,13 +57,6 @@ packages/ # The auto-generated palette palettes/LAST_EXPORT.zip -# Error file -BEE2-error.log - -# Ignore dev output -test_out.vmf -preview_styled.vmf - #These folders are generated automatically by the BEE2 images/cache/ inst_cache/ @@ -75,5 +68,6 @@ config/item_configs.cfg config/config.cfg config/games.cfg config/compile.cfg +config/packages.cfg config/voice/ config/screenshot.jpg \ No newline at end of file diff --git a/src/packageLoader.py b/src/packageLoader.py index 148f515fd..86d16d5fc 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -11,6 +11,7 @@ from FakeZip import FakeZip, zip_names from selectorWin import SelitemData from loadScreen import main_loader as loader +from packageMan import PACK_CONFIG import vmfLib as VLib import extract_packages import utils @@ -29,9 +30,11 @@ ObjData = namedtuple('ObjData', 'zip_file, info_block, pak_id, disp_name') ParseData = namedtuple('ParseData', 'zip_file, id, info, pak_id') -PackageData = namedtuple('package_data', 'zip_file, info, name, disp_name') ObjType = namedtuple('ObjType', 'cls, allow_mult, has_img') +# This package contains necessary components, and must be available. +CLEAN_PACKAGE = 'BEE2_CLEAN_STYLE' + def pak_object(name, allow_mult=False, has_img=True): """Decorator to add a class to the list of objects. @@ -102,8 +105,9 @@ def get_config( path += extension try: with zip_file.open(path) as f: - return Property.parse(f, - pak_id + ':' + path, + return Property.parse( + f, + pak_id + ':' + path, ) except KeyError: print('"{}:{}" not in zip!'.format(pak_id, path)) @@ -131,15 +135,11 @@ def find_packages(pak_dir, zips, zip_name_lst): with zip_file.open('info.txt') as info_file: info = Property.parse(info_file, name + ':info.txt') pak_id = info['ID'] - disp_name = info['Name', None] - if disp_name is None: - print('Warning: {} has no display name!'.format(pak_id)) - disp_name = pak_id.lower() - packages[pak_id] = PackageData( + packages[pak_id] = Package( + pak_id, zip_file, info, name, - disp_name, ) found_pak = True else: @@ -195,21 +195,23 @@ def load_packages( data[obj_type] = [] images = 0 - for pak_id, (zip_file, info, name, dispName) in packages.items(): + for pak_id, pack in packages.items(): + if not pack.enabled: + print('Package {} disabled!'.format(pack.id).ljust(50)) + continue + print( ("Reading objects from '" + pak_id + "'...").ljust(50), end='' ) - img_count = parse_package( - zip_file, - info, - pak_id, - dispName, - ) + img_count = parse_package(pack) images += img_count loader.step("PAK") print("Done!") + # If new packages were added, update the config! + PACK_CONFIG.save_check() + loader.set_length("OBJ", sum( len(obj_type) for obj_type in @@ -287,16 +289,16 @@ def load_packages( return data -def parse_package(zip_file, info, pak_id, disp_name): +def parse_package(pack: 'Package'): """Parse through the given package to find all the components.""" - for pre in Property.find_key(info, 'Prerequisites', []).value: + for pre in Property.find_key(pack.info, 'Prerequisites', []): if pre.value not in packages: utils.con_log( - 'Package "' + - pre.value + - '" required for "' + - pak_id + - '" - ignoring package!' + 'Package "{pre}" required for "{id}" - ' + 'ignoring package!'.format( + pre=pre.value, + id=pack.id, + ) ) return False # First read through all the components we have, so we can match @@ -304,32 +306,32 @@ def parse_package(zip_file, info, pak_id, disp_name): for comp_type in OBJ_TYPES: allow_dupes = OBJ_TYPES[comp_type].allow_mult # Look for overrides - for obj in info.find_all("Overrides", comp_type): + for obj in pack.info.find_all("Overrides", comp_type): obj_id = obj['id'] obj_override[comp_type][obj_id].append( - ParseData(zip_file, obj_id, obj, pak_id) + ParseData(pack.zip, obj_id, obj, pack.id) ) - for obj in info.find_all(comp_type): + for obj in pack.info.find_all(comp_type): obj_id = obj['id'] if obj_id in all_obj[comp_type]: if allow_dupes: # Pretend this is an override obj_override[comp_type][obj_id].append( - ParseData(zip_file, obj_id, obj, pak_id) + ParseData(pack.zip, obj_id, obj, pack.id) ) else: raise Exception('ERROR! "' + obj_id + '" defined twice!') all_obj[comp_type][obj_id] = ObjData( - zip_file, + pack.zip, obj, - pak_id, - disp_name, + pack.id, + pack.disp_name, ) img_count = 0 img_loc = os.path.join('resources', 'bee2') - for item in zip_names(zip_file): + for item in zip_names(pack.zip): item = os.path.normcase(item).casefold() if item.startswith("resources"): extract_packages.res_count += 1 @@ -480,6 +482,44 @@ def parse_item_folder(folders, zip_file, pak_id): except KeyError: folders[fold]['vbsp'] = Property(None, []) +class Package: + """Represents a package.""" + def __init__( + self, + pak_id: str, + zip_file: ZipFile, + info: Property, + name: str, + ): + disp_name = info['Name', None] + if disp_name is None: + print('Warning: {} has no display name!'.format(pak_id)) + disp_name = pak_id.lower() + + self.id = pak_id + self.zip = zip_file + self.info = info + self.name = name + self.disp_name = disp_name + self.desc = info['desc', ''] + + @property + def enabled(self): + """Should this package be loaded?""" + if self.id == CLEAN_PACKAGE: + # The clean style package is special! + # It must be present. + return True + + return PACK_CONFIG.get_bool(self.id, 'Enabled', default=True) + + @enabled.setter + def enabled(self, value: bool): + if self.id == CLEAN_PACKAGE: + raise ValueError('The Clean Style package cannot be disabled!') + + PACK_CONFIG[self.id]['Enabled'] = utils.bool_as_int(value) + @pak_object('Style') class Style: diff --git a/src/packageMan.py b/src/packageMan.py new file mode 100644 index 000000000..84737c889 --- /dev/null +++ b/src/packageMan.py @@ -0,0 +1,13 @@ +"""Allows enabling and disabling individual packages. +""" +from tkinter import ttk +import tkinter as tk +from tk_tools import TK_ROOT + +from CheckDetails import CheckDetails, Item as CheckItem +from BEE2_config import ConfigFile +import packageLoader +import utils +import tk_tools + +PACK_CONFIG = ConfigFile('packages.cfg') From 3d7c2e7fbb3b764609c7153eb45240ddb3a1277f Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 11:58:28 +1000 Subject: [PATCH 84/91] Make setting the length of a stage update the bar correctly --- src/loadScreen.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/loadScreen.py b/src/loadScreen.py index c3d1f3430..1c2a9afb2 100644 --- a/src/loadScreen.py +++ b/src/loadScreen.py @@ -97,13 +97,13 @@ def step(self, stage): """Increment a step by one.""" if self.active: self.bar_val[stage] += 1 - self.bar_var[stage].set( - 1000 * self.bar_val[stage] / self.maxes[stage] - ) - self.widgets[stage].update() self.set_nums(stage) + self.widgets[stage].update() def set_nums(self, stage): + self.bar_var[stage].set( + 1000 * self.bar_val[stage] / self.maxes[stage] + ) self.labels[stage]['text'] = '{!s}/{!s}'.format( self.bar_val[stage], self.maxes[stage], From 75937cdaf0940bdb6e75e3486ac566a140cda29d Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 11:59:26 +1000 Subject: [PATCH 85/91] Make Package.set_enabled acessable, and remove disabled packages from the loading bark --- src/packageLoader.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/packageLoader.py b/src/packageLoader.py index 86d16d5fc..d69e66be9 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -16,10 +16,15 @@ import extract_packages import utils +from typing import ( + Union, Optional, + List, Dict, Tuple, +) + all_obj = {} obj_override = {} -packages = {} +packages = {} # type: Dict[str, Package] OBJ_TYPES = {} data = {} @@ -187,7 +192,8 @@ def load_packages( try: find_packages(pak_dir, zips, data['zips']) - loader.set_length("PAK", len(packages)) + pack_count = len(packages) + loader.set_length("PAK", pack_count) for obj_type in OBJ_TYPES: all_obj[obj_type] = {} @@ -198,6 +204,8 @@ def load_packages( for pak_id, pack in packages.items(): if not pack.enabled: print('Package {} disabled!'.format(pack.id).ljust(50)) + pack_count -= 1 + loader.set_length("PAK", pack_count) continue print( @@ -482,6 +490,7 @@ def parse_item_folder(folders, zip_file, pak_id): except KeyError: folders[fold]['vbsp'] = Property(None, []) + class Package: """Represents a package.""" def __init__( @@ -513,12 +522,12 @@ def enabled(self): return PACK_CONFIG.get_bool(self.id, 'Enabled', default=True) - @enabled.setter - def enabled(self, value: bool): + def set_enabled(self, value: bool): if self.id == CLEAN_PACKAGE: raise ValueError('The Clean Style package cannot be disabled!') PACK_CONFIG[self.id]['Enabled'] = utils.bool_as_int(value) + enabled.setter(set_enabled) @pak_object('Style') From 39ef4241b1eb740f689ed75b7c28efb6fb100632 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 13:15:41 +1000 Subject: [PATCH 86/91] Add ability to lock a checkItem row This prevents the checkbox from being toggled at all. --- src/CheckDetails.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index c401dd384..90b561703 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -54,11 +54,26 @@ class Item: """Represents one item in a CheckDetails list. """ - def __init__(self, *values, hover_text=None): + def __init__( + self, + *values, + hover_text=None, + callback=None, + lock_check=False + ): + """Initialise an item. + - values are the text to show in each column, in order. + - hover_text will set text to show in the tooltip. If not defined, + tooltips will be used to show any text that does not fit + in the column width. + - callback is a function to be called with the state, whenever changed. + - If lock_check is true, this checkbox cannot be changed. + """ self.values = values self.state_var = tk.IntVar(value=0) self.master = None # type: CheckDetails self.check = None # type: ttk.Checkbutton + self.locked = lock_check self.hover_text = hover_text self.val_widgets = [] @@ -84,6 +99,8 @@ def make_widgets(self, master: 'CheckDetails'): style='CheckDetails.TCheckbutton', command=self.master.update_allcheck, ) + if self.locked: + self.check.state(['readonly']) self.val_widgets = [] for value in self.values: @@ -102,11 +119,12 @@ def make_widgets(self, master: 'CheckDetails'): wid.tooltip_text = '' wid.hover_override = False - # Allow clicking on the row to toggle the checkbox - wid.bind('', self.hover_start, add='+') - wid.bind('', self.hover_stop, add='+') - utils.bind_leftclick(wid, self.row_click, add='+') - wid.bind(utils.EVENTS['LEFT_RELEASE'], self.row_unclick, add='+') + if not self.locked: + # Allow clicking on the row to toggle the checkbox + wid.bind('', self.hover_start, add='+') + wid.bind('', self.hover_stop, add='+') + utils.bind_leftclick(wid, self.row_click, add='+') + wid.bind(utils.EVENTS['LEFT_RELEASE'], self.row_unclick, add='+') self.val_widgets.append(wid) @@ -369,8 +387,12 @@ def update_allcheck(self): def toggle_allcheck(self): value = self.head_check_var.get() - for item in self.items: - # Bypass the update function + for item in self.items: # type: Item + if item.locked: + continue # Don't change! + + # We can't use item.state, since that calls update_allcheck() + # which would infinite-loop. item.state_var.set(value) if value and self.items: # Don't enable if we don't have items self.event_generate(EVENT_HAS_CHECKS) From e10af0a6b7b9fada92f2390e3c2d39c5e4f5bca2 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 13:16:35 +1000 Subject: [PATCH 87/91] Add Packag manager to the UI --- src/BEE2.pyw | 2 +- src/UI.py | 10 ++++++- src/packageMan.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/BEE2.pyw b/src/BEE2.pyw index 1c00d5915..7c7761efd 100644 --- a/src/BEE2.pyw +++ b/src/BEE2.pyw @@ -62,7 +62,7 @@ if __name__ == '__main__': 'log_missing_ent_count': '0', }, } - loadScreen.main_loader.set_length('UI', 13) + loadScreen.main_loader.set_length('UI', 14) loadScreen.main_loader.show() if utils.MAC: diff --git a/src/UI.py b/src/UI.py index 5f75b5afe..8004cee54 100644 --- a/src/UI.py +++ b/src/UI.py @@ -22,6 +22,7 @@ import voiceEditor import contextWin import gameMan +import packageMan import StyleVarPane import CompilerPane import tagsPane @@ -1467,9 +1468,13 @@ def init_menu_bar(win): command=gameMan.remove_game, ) file_menu.add_command( - label="Backup/Restore Puzzles", + label="Backup/Restore Puzzles...", command=backupWin.show_window, ) + file_menu.add_command( + label="Manage Packages...", + command=packageMan.show, + ) file_menu.add_separator() file_menu.add_command( label="Options", @@ -1639,6 +1644,9 @@ def init_windows(): init_palette(pal_frame) loader.step('UI') + packageMan.make_window() + loader.step('UI') + windows['opt'] = SubPane.SubPane( TK_ROOT, options=GEN_OPTS, diff --git a/src/packageMan.py b/src/packageMan.py index 84737c889..da72a33cf 100644 --- a/src/packageMan.py +++ b/src/packageMan.py @@ -1,6 +1,7 @@ """Allows enabling and disabling individual packages. """ from tkinter import ttk +from tkinter import messagebox import tkinter as tk from tk_tools import TK_ROOT @@ -10,4 +11,72 @@ import utils import tk_tools +window = tk.Toplevel(TK_ROOT) +window.withdraw() + +UI = {} + PACK_CONFIG = ConfigFile('packages.cfg') + +pack_items = {} + +HEADERS = ['Name'] + + +def show(): + """Show the manager window.""" + window.deiconify() + utils.center_win(window, TK_ROOT) + window.after(100, UI['details'].refresh) + + +def make_packitems(): + """Make the checkitems used in the details view.""" + pack_items.clear() + for pack in packageLoader.packages.values(): # type: packageLoader.Package + pack_items[pack.id] = item = CheckItem( + pack.disp_name, + hover_text=pack.desc or None, + # The clean package can't be disabled! + lock_check=(pack.id == packageLoader.CLEAN_PACKAGE), + ) + item.state = pack.enabled + item.package = pack + return pack_items.values() + + +def make_window(): + """Initialise the window.""" + window.transient(TK_ROOT) + window.title('BEE2 - Manage Packages') + + # Don't destroy window when quit! + window.protocol("WM_DELETE_WINDOW", cancel) + + frame = ttk.Frame(window) + frame.grid(row=0, column=0, sticky='NSEW') + window.columnconfigure(0, weight=1) + window.rowconfigure(0, weight=1) + + UI['details'] = CheckDetails( + frame, + headers=HEADERS, + items=make_packitems(), + ) + + UI['details'].grid(row=0, column=0, sticky='NSEW') + frame.columnconfigure(0, weight=1) + frame.rowconfigure(0, weight=1) + + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=0, column=1, sticky='NSE') + + ttk.Button( + btn_frame, + text='Ok', + ).grid(row=0, column=0) + + ttk.Button( + btn_frame, + text='Cancel', + ).grid(row=1, column=0) \ No newline at end of file From a8175b8d3e862c926467d0356c727256b186d15b Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 13:42:42 +1000 Subject: [PATCH 88/91] Add ability to set the initial state of a checkitem --- src/CheckDetails.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CheckDetails.py b/src/CheckDetails.py index 90b561703..40a1fcde6 100644 --- a/src/CheckDetails.py +++ b/src/CheckDetails.py @@ -58,19 +58,19 @@ def __init__( self, *values, hover_text=None, - callback=None, - lock_check=False + lock_check=False, + state=False ): """Initialise an item. - values are the text to show in each column, in order. - hover_text will set text to show in the tooltip. If not defined, tooltips will be used to show any text that does not fit in the column width. - - callback is a function to be called with the state, whenever changed. - If lock_check is true, this checkbox cannot be changed. + - state is the initial state of the checkbox. """ self.values = values - self.state_var = tk.IntVar(value=0) + self.state_var = tk.IntVar(value=bool(state)) self.master = None # type: CheckDetails self.check = None # type: ttk.Checkbutton self.locked = lock_check @@ -100,7 +100,7 @@ def make_widgets(self, master: 'CheckDetails'): command=self.master.update_allcheck, ) if self.locked: - self.check.state(['readonly']) + self.check.state(['disabled']) self.val_widgets = [] for value in self.values: From eb9145debbb18970abbad1364ac78937153b3fd1 Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 13:44:06 +1000 Subject: [PATCH 89/91] Add function for restarting the app. --- src/utils.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/utils.py b/src/utils.py index 1e666f399..7b78c07b6 100644 --- a/src/utils.py +++ b/src/utils.py @@ -539,6 +539,23 @@ def fit(dist, obj): return list(items) # Dump the deque +def restart_app(): + """Restart this python application. + + This will not return! + """ + import os, sys + # sys.executable is the program which ran us - when frozen, + # it'll our program. + # We need to add the program to the arguments list, since python + # strips that off. + args = [sys.executable] + sys.argv + print('Restarting using "{}", with args {!r}'.format( + sys.executable, args + ), flush=True) + os.execv(sys.executable, args) + + class EmptyMapping(abc.Mapping): """A Mapping class which is always empty.""" __slots__ = [] From 7a9f4c87a9c15b56fba183c5aa63b477b335ad5f Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 13:44:22 +1000 Subject: [PATCH 90/91] Fix enabled property() --- src/packageLoader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packageLoader.py b/src/packageLoader.py index d69e66be9..e069b220a 100644 --- a/src/packageLoader.py +++ b/src/packageLoader.py @@ -527,7 +527,7 @@ def set_enabled(self, value: bool): raise ValueError('The Clean Style package cannot be disabled!') PACK_CONFIG[self.id]['Enabled'] = utils.bool_as_int(value) - enabled.setter(set_enabled) + enabled = enabled.setter(set_enabled) @pak_object('Style') From b98a2ab8960c56058001e6f46091dc1dd6a3c74a Mon Sep 17 00:00:00 2001 From: TeamSpen210 Date: Sun, 22 Nov 2015 13:44:45 +1000 Subject: [PATCH 91/91] Make package manager work --- src/packageMan.py | 54 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/packageMan.py b/src/packageMan.py index da72a33cf..6102a4a86 100644 --- a/src/packageMan.py +++ b/src/packageMan.py @@ -9,7 +9,6 @@ from BEE2_config import ConfigFile import packageLoader import utils -import tk_tools window = tk.Toplevel(TK_ROOT) window.withdraw() @@ -26,6 +25,8 @@ def show(): """Show the manager window.""" window.deiconify() + window.lift(TK_ROOT) + window.grab_set() utils.center_win(window, TK_ROOT) window.after(100, UI['details'].refresh) @@ -36,15 +37,47 @@ def make_packitems(): for pack in packageLoader.packages.values(): # type: packageLoader.Package pack_items[pack.id] = item = CheckItem( pack.disp_name, - hover_text=pack.desc or None, + hover_text=pack.desc or 'No description!', # The clean package can't be disabled! lock_check=(pack.id == packageLoader.CLEAN_PACKAGE), + state=pack.enabled ) - item.state = pack.enabled item.package = pack return pack_items.values() +def apply_changes(): + values_changed = any( + item.package.enabled != item.state + for item in + pack_items.values() + ) + if not values_changed: + # We don't need to do anything! + window.withdraw() + return + + if messagebox.askokcancel( + title='BEE2 - Restart Required!', + message='Changing enabled packages requires a restart.\n' + 'Continue?', + master=window, + ): + window.withdraw() + for item in UI['details'].items: + pack = item.package + if pack.id != packageLoader.CLEAN_PACKAGE: + pack.enabled = item.state + PACK_CONFIG.save_check() + utils.restart_app() + + +def cancel(): + window.withdraw() + UI['details'].remove_all() + UI['details'].add_items(*make_packitems()) + + def make_window(): """Initialise the window.""" window.transient(TK_ROOT) @@ -64,19 +97,18 @@ def make_window(): items=make_packitems(), ) - UI['details'].grid(row=0, column=0, sticky='NSEW') + UI['details'].grid(row=0, column=0, columnspan=2, sticky='NSEW') frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) - btn_frame = ttk.Frame(frame) - btn_frame.grid(row=0, column=1, sticky='NSE') - ttk.Button( - btn_frame, + frame, text='Ok', - ).grid(row=0, column=0) + command=apply_changes, + ).grid(row=1, column=0, sticky='W') ttk.Button( - btn_frame, + frame, text='Cancel', - ).grid(row=1, column=0) \ No newline at end of file + command=cancel, + ).grid(row=1, column=1, sticky='E') \ No newline at end of file