Noch ne Designfrage

Wenn du dir nicht sicher bist, in welchem der anderen Foren du die Frage stellen sollst, dann bist du hier im Forum für allgemeine Fragen sicher richtig.
Antworten
T.T. Kreischwurst
User
Beiträge: 52
Registriert: Dienstag 2. Februar 2016, 10:56

Servus!

Ich habe ein Design-Problem, bei dem ich feststecke und ohne Hilfe nicht weiter komme. Hintergrund ist immernoch mein Arbeitszeit-Verwaltungsprogramm, Details hier.
Es geht um die saubere Gestaltung von GUI Code, konkret eines tk.Canvas Objekts mit diversen Items darauf, die miteinander interagieren und weitere Aktionen bedingen. Knackpunkt ist dabei m.E., dass die Canvas items sich nicht besonders gut als eigenständige (Unter)klassen modellieren lassen, da sie einerseits ohne das Canvas Objekt nichts selbst können, andererseits aber miteinander öfters in Verbindung stehen. Eine einzige Canvas-Klasse, welche die Items als Attribute und die entsprechenden Aktionen als Methoden nimmt, wäre naheliegend, aber in der Praxis völlig untauglich. Die Klasse wird riesig und völlig unlesbar.
Zum besseren Verständnis ein grober Überblick, was ich mir gedacht habe:
Es gibt eine Arbeitsfläche (= tk.Canvas Objekt)
Auf dieser Arbeitsfläche gibt es – grob gesagt – zwei Bereiche: links steht eine Liste mit Jobbzeichnungen, rechts ein Raster/Tabelle.
Jedes Feld im Raster steht für ein Zeitfenster von 15 Minuten an einem Datum, das der User setzen kann (Default=heute)
Der Nutzer kann nun einen Job aus der Liste per Drag-and-Drop über ein Feld ziehen. Damit setzt er eine Verbindung zwischen diesem Job und dem jeweiligen Zeitfenster, die später in einer Datenbank gespeichert wird.
Dieser Prozess ist von einer Reihe graphischen bling-blings begleitet: fährt der Nutzer mit der Maus über einen Job aus der Liste, wird dieser blau hinterlegt (die Schrift wird weiß); fährt der Nutzer mit einem ausgewählten Job (also mit gedrückter linker Maustaste) über ein Feld im Raster, wird dieses mit einem blauen Rahmen versehen; legt er einen Job in einem Feld ab, wird dieses grün gefüllt und der Job wird in das Kästchen geschrieben.
Die verwendeten Canvas-Items sind hauptsächlich Text-items und rectangles
Bislang habe ich drei Klassen erstellt: eine Tabellen-Klasse, eine Rechteck-Klasse und eine Jobs-Klasse. Die Tabellenklasse fungiert als Steuerungsklasse, wird dadurch aber schon wieder ziemlich umfangreich. Außerdem finde ich das ganze Konzept ziemlich umständlich.
Das Modellierungsproblem besteht nun darin, dass hauptsächlich für die graphischen Hervorhebungen und die Verbindung zwischen Kästchen bzw. diesem zugeordneter Zeit und dem Job/Text-Item direkte Abhängigkeiten bestehen. Also entweder ich hole mir eine mords umständliche Steuerklasse oder ich produziere total unübersichtliche Abhängigkeiten zwischen den einzelnen Klassen…
Wie würdet ihr an so ein Problem herangehen? Gibt es bestimmte Entwurfsmuster, die hier besser als andere greifen? Hilfe…das Zeug funktioniert schon in der dritten unterscheidlichen Version einwandfrei, aber keine Version erscheint mir bisher designtechnisch auch nur ansatzweise vertretbar.

PS: ich poste bei Bedarf gerne den bisherigen Code, habe aber bisher bewusst darauf verzichtet, da ich den "Blick von Außen" auf das Problem nicht verstellen wollte.
Sirius3
User
Beiträge: 17737
Registriert: Sonntag 21. Oktober 2012, 17:20

@T.T. Kreischwurst: erster Schritt wäre, eine generelle Tabellenklasse zu schreiben, die Drap-Drop und Blingbling beherrscht. Diese spricht eine generisches Item-Objekt an, dem man verschiedene Stati geben kann (aktiv, hover, etc). Davon abgeleitet schreibst Du die konkrete Job-Tabelle, die dann nichts mehr mit der Darstellung zu tun haben sollte.
T.T. Kreischwurst
User
Beiträge: 52
Registriert: Dienstag 2. Februar 2016, 10:56

Danke, Sirius, das ist schon mal interessant. Dazu zwei Fragen: läuft man mit einer generellen Tabellenklasse nicht Gefahr, wieder so eine Art Gottklasse zu schreiben? Jedenfalls denke ich spontan an sowas wie meine Steuerklasse... Zum anderen habe ich gerade Schwierigkeiten, mir die konkrete Jobtabelle gegenüber der abstrakten vorzustellen, wobei die konkrete aber nichts mit der Darstellung zu tun hat :?:
Sirius3
User
Beiträge: 17737
Registriert: Sonntag 21. Oktober 2012, 17:20

@T.T. Kreischwurst: ich meinte jetzt nicht Tabelle im Kontext Datenbank, sondern die visuelle Darstellung als Tabelle. Und nein, das ist dann keine Gott-Klasse, sondern eben nur eine Hilfsklasse zur Darstellung.
T.T. Kreischwurst
User
Beiträge: 52
Registriert: Dienstag 2. Februar 2016, 10:56

Achso… hm, dann poste ich doch mal meinen bisherigen Code. Wenn ich dich richtig verstehe, geht das durchaus schon in die von dir vorgeschlagene Richtung. Nur eben mit Verbesserungsbedarf.
Dies hier ist wie gesagt nur der Teil, der für die Canvas-basierten Sachen zuständig ist, also keine Datenbank Funktionalität und auch nix vom Hauptmenü. Nicht wundern: die Tabelle heißt hier Kalender, das muss ich noch umbenennen.

Code: Alles auswählen

class Calendar(MainWindow):
    
    def __init__(self, db, root, date):
        
        ''' This class is designed as some kind of a mediator. Its most important attribute is a tk.canvas object and it manages the interaction between the various canvas widgets that have come might come onto the canvas in the future. '''
        
        MainWindow.__init__(self, root)
        self.db         = db
        self.se_times   = self.set_datetime(date)
        self.rectangle  = None                                      # "pattern" rectangle object
        self.jobs       = None
        self.active     = None                                      # The currently active rectangle (which the cursor is over)
        self.job_info   = []
        self.canvas     = tk.Canvas(root,
                            width=int(self.screen_width*0.7),
                            height=int(self.screen_height*0.85),
                            bd=3,
                            relief=tk.GROOVE)
        self.canvas.grid(
                    row     = 0,
                    column  = 1,
                    padx    = int(self.screen_width*0.03),
                    pady    = int(self.screen_height*0.04))
        self.place_widgets()
        
    def place_widgets(self):
        
        ''' Instances of all Canvas widgets are invoked here; this is intended as a central entry point for all graphical stuff that might come onto the canvas at some time. '''
        
        self.rectangle = _Rectangle(self.canvas, self.se_times)
        
        joblist = self.db.jobs_select_all()
        self.jobs = _Jobs(self.canvas, joblist)
        
        self.event_bindings()
        
    def event_bindings(self):
        
        ''' Event bindings for Drag and Drop, rightclicking and maybe other events are defined here. '''
        
        # D&D events: left mouse button. 'text' is a tag-group assigned to all jobtiles in class Job
        for text in self.jobs.IDs:
            self.canvas.tag_bind(text, '<ButtonPress-1>', self.jobs.copy_job)
            self.canvas.tag_bind(text, '<B1-Motion>', self.move_job)
            self.canvas.tag_bind(text, '<ButtonRelease-1>', self.drop_job)
        
    def drop_job(self, event):
        
        ''' Perform the overlap check :-) then fill the matching rectangle in green and delete the double text item. '''
        
        match = self.check_overlap()
        if match:   # If Job is dropped outside grid, check_overlap returns none
            text = self.jobs.IDs[self.jobs.active_ID]
            self.rectangle.fill_if_matched(match, text)
            self.rectangle.write_jobtext(match, text)
            self.update_jobinfo(text, match)
                
        self.canvas.delete(self.jobs.job_double)
        
    def update_jobinfo(self, text, match):
        
        ''' Set the info generated from the dropped field to the respective attribute. '''
        
        match_start = self.rectangle.field_info[match][0]
        match_end = self.rectangle.field_info[match][1]
        self.job_info.append((self.jobs.active_ID, match_start, match_end))
    
    def check_overlap(self):
        
        ''' Checks if the doubled text item does overlap with any rectangle and if so, which one. '''
        
        text_double_coords = self.canvas.coords(self.jobs.job_double)
        
        for rect in self.rectangle.IDs:
            rect_coords = self.canvas.coords(rect)
            if rect_coords[0] <= text_double_coords[0] <= rect_coords[2] and \
               rect_coords[1] <= text_double_coords[1] <= rect_coords[3]:
                return rect
           
    def move_job(self, event):
        
        ''' Moves the doubled job-text-item around. Controls highlighting. NOTE: this method could be expected inside the _Jobs class, but if it would be expanded at some point (which is not unlikely), access to both _Jobs and _Rectangle methods is probably needed. So we have to store it in the mediator class'''
        
        self.canvas.coords(self.jobs.job_double, event.x, event.y)
        
        # Check the currently hovered rectangle and if it's active yet.
        # Then highlight it and possibly remove the old highlighting
        current = self.check_overlap()
        if current == self.active:
            self.highlight_current(current)
        else:
            self.delete_highlight()
            self.active = current
    
    def highlight_current(self, current):
        
        ''' Highlights the currently hovered rectangle. '''
        
        self.canvas.itemconfig(current, outline='blue', width=3)
        
    def delete_highlight(self):
        
        ''' Removes the highlight around the current rectangle when the cursor leaves it. '''
        
        self.canvas.itemconfig(self.active, outline='black', width=1)
            
    def set_datetime(self, date):
        
        ''' Combine the given date with the start and end time (default is 07:00/21:00) and return this new datetime object. '''
        
        start   = datetime.time(hour=7)
        end     = datetime.time(hour=21)
        
        start_datetime = datetime.datetime.combine(date, start)
        end_datetime = datetime.datetime.combine(date, end)
        
        return [start_datetime, end_datetime]

class _Rectangle():
    
    def __init__(self, canvas, se_times):
        
        ''' Just the graphical stuff related to the grid. Prints rectangles onto the canvas, but no data are linked here '''
        
        self.canvas     = canvas
        self.se_times   = se_times
        self.increment  = datetime.timedelta(seconds=900)           # 15 minutes 
        self.field_info = {}
        self.width      = 0
        self.height     = 0
        self.x_coords   = []
        self.y_coords   = []
        self.IDs        = []
        
        self.set_size()
        
    def set_size(self):
        
        self.canvas.update()        # Necessary because winfo_width() will otherwise always return 1 - no idea why...
        self.width = self.canvas.winfo_width() / 6  # 2/3 are preserved for the rectagnles, the rest remains free for the jobtiles 
        self.height = self.canvas.winfo_height() / 16.5 # 15 Rows, a label and some padding
        self.set_coords()
        
    def set_coords(self):
        
        x_counter = self.width * 1.5      # Initial value is 1.5 tile's distance from left border (left clear for jobtiles).
        self.x_coords.append(x_counter) # Append initial value before loop, classical group change issue
        
        for x in range(4):
            x_counter += self.width
            self.x_coords.append(x_counter)
            
        y_counter = self.height     # Initial value is one tile's distance from top border (left clear for label)
        self.y_coords.append(y_counter)
        for y in range(14):
            y_counter += self.height
            self.y_coords.append(y_counter)
        self.draw_rectangles()
            
    def draw_rectangles(self):
        
        for y in range(14):
            
            # fetch the y coords
            y1 = self.y_coords[y]
            y2 = self.y_coords[y+1]
            
            # inner for loop: fetch x coords and
            # draw the rectangle
            for x in range(4):
                x1 = self.x_coords[x]
                x2 = self.x_coords[x+1]
                
                rect = self.canvas.create_rectangle(x1,y1,x2,y2)
                self.IDs.append(rect)
        self.set_rect_startEnd()
                
    def set_rect_startEnd(self):
        
        ''' Define start/end times for EACH particular rectangle. '''

        for rect in self.IDs:
            start   = self.se_times[0]
            end     = self.se_times[0] + self.increment
            
            # write the end time into the rectangle (=create a text item)
            # Is replaced by job name when a job was dropped here.
            displayed_item = self.write_time(rect, end)
            
            # save start/end for this rectangle
            self.field_info.update(
                {rect:[start, end, displayed_item]})
                
            # continue the loop: new start = former end
            self.se_times[0] = end
    
    def write_time(self, current_rect, end_time):
        
        ''' Create a text item displaying the end time in the background of the current rectangle'''
        
        rect_bbox = self.canvas.bbox(current_rect)
        x_coord = rect_bbox[2] - (rect_bbox[2] - rect_bbox[0]) / 2
        y_coord = rect_bbox[3] - (rect_bbox[3] - rect_bbox[1]) / 2
        text = 'bis ' + str(end_time.time())
        displayed_time = self.canvas.create_text(x_coord, y_coord, text=text, fill='#AFABAB')
        
        # return the ID of the text object so we can save it. The text needs to be removed when a job is dropped here,
        # so we have to be able to identify the background text item.
        return displayed_time
        
    def write_jobtext(self, rect, text):
        
        ''' Fill in the dropped job as new text item. '''
        
        x_coord = int(self.canvas.coords(rect)[0] + 20)
        y_coord = int(self.canvas.coords(rect)[1] + 10)
        jobname = self.canvas.create_text(x_coord,
                                y_coord,
                                text=text,
                                width=int(self.width - 10),
                                font=('Segoe UI', 10),
                                fill='white')
        self.field_info[rect][2] = jobname
        
    def change_jobtext(self, old, new):
        
        for id, textitem in self.field_info.items():
            if self.canvas.itemcget(textitem[2], 'text') == old:
                self.canvas.itemconfig(textitem[2], text=new)
        
    def fill_if_matched(self, rect, text):
        
        ''' If a rectangle is matched in Calendar.check_overlap(), this function is called. Fills the rectangle in green, creates a new textitem displaying the dropped job, and deletes the time slot that was previously displayed.'''
        
        self.canvas.itemconfig(rect, fill='green')
        
        # Delete the time-text-item in the background
        displayed_time = self.field_info[rect][2]
        self.canvas.delete(displayed_time)
                
class _Jobs():
    
    def __init__(self, canvas, joblist):
        
        self.canvas         = canvas
        self.joblist        = joblist
        self.highlighted    = None
        self.job_double     = None
        self.active_ID      = None      # ID of text item/job that was dragged last - used for easier access when saved
        self.max_width      = int(self.canvas.winfo_width() / 4.5)
        self.x_coord        = 0
        self.y_coord        = 0
        self.IDs            = {}
        self.set_coords()
     
    def set_coords(self):
        
        self.x_coord = self.canvas.winfo_width() * 0.05
        self.y_coord = self.canvas.winfo_height() * 0.03
        self.place_joblist() 
        
    def place_joblist(self):
        
        for job in self.joblist.values():
            text = self.canvas.create_text(
                    self.x_coord,
                    self.y_coord,
                    text=job,
                    tag=job,
                    width=self.max_width,
                    anchor=tk.NW)
            self.IDs.update({text:self.canvas.itemcget(text, 'text')})
            
            self.canvas.tag_bind(text,'<Enter>', self.fill)
            self.canvas.tag_bind(text,'<Leave>', self.delete_backgr)
            
            # increment the y-coord, consider the padding of the background!
            y_increment = self.canvas.bbox(text)[3] - self.y_coord + 22
            self.y_coord += y_increment
        
    def fill(self, event=None):
        
        # Base size for background is the text's bbox; we add a padding of 10 here.
        size = self.canvas.bbox(tk.CURRENT)
        size = (size[0] - 10, size[1] - 10, size[2] + 10, size[3] + 10)
        
        self.canvas.itemconfig(tk.CURRENT, activefill='white')
        rect = self.canvas.create_rectangle(size, fill='blue', width=0)
        self.canvas.tag_lower(rect, 'joblist')
        self.highlighted = rect
        
    def delete_backgr(self, event=None):
        
        self.canvas.delete(self.highlighted)
        
    def remove_job(self, job):
        
        for id, text in self.IDs.items():
            if text == job:
                self.canvas.delete(id)
                
    def change_text(self, job, new_job):
        
        for id, text in self.IDs.items():
            if text == job:
                self.canvas.itemconfig(id, text=new_job)
                self.IDs[id] = new_job
                
    def copy_job(self, event):
        
        ''' On click onto the jobtext, it will be doubled - this double is what is moved around. '''
        
        self.job_double = self.canvas.create_text(
                event.x,
                event.y,
                text=self.canvas.itemcget(tk.CURRENT, 'text'),
                font=('Segoe UI', 13, 'bold'),
                fill='blue',
                anchor=tk.NW,
                width=self.max_width
                )
        
        # Set the itemID as an attribute, so we can access the IDs dictionary directly later
        self.active_ID = self.canvas.find_withtag(tk.CURRENT)[0]
Antworten