Repository: psychopy/psychopy

Issue:
Builder: longer component names truncated
In the routine view, component names longer than about 10 characters get cut off, even when "View > Routine smaller" is set to the smallest text size.  See image below.

Any method of changing the ratio of space given to the names vs. the timeline would be much appreciated.  For me, at least, the component names are more important than the timeline.

![adf314ceca9dbbfe2595aa66d3641c844df47405_2_1380x900](https://user-images.githubusercontent.com/2888701/236318071-fa32078a-1a35-43f4-baa8-59069e4214b4.jpeg)



Discussion:
Ideally I'd like to have a draggable sash like we have between panels, but as the Routine Canvas is all custom via a PseudoDC this would have to be coded from scratch which... could get crunchy.

One option could be that we set the width of the section with the names according to the length of the longest name? With an option in preferences to disable this and just have a fixed size like it is now (for people with extremely long component names and a need for more timeline space)

Resolving patch:
diff --git a/psychopy/app/builder/builder.py b/psychopy/app/builder/builder.py
--- a/psychopy/app/builder/builder.py
+++ b/psychopy/app/builder/builder.py
@@ -1695,6 +1695,8 @@ def __init__(self, notebook, id=wx.ID_ANY, routine=None):
         self.curLine = []
         self.drawing = False
         self.drawSize = self.app.prefs.appData['routineSize']
+        # dict in which to store rectangles to aid layout (populated in updateLayoutRects)
+        self.rects = {}
         # auto-rescale based on number of components and window size is jumpy
         # when switch between routines of diff drawing sizes
         self.iconSize = (24, 24, 48)[self.drawSize]  # only 24, 48 so far
@@ -1910,21 +1912,20 @@ def OnPaint(self, event):
         self.pdc.DrawToDCClipped(dc, r)
 
     def redrawRoutine(self):
-        self.pdc.Clear()  # clear the screen
-        self.pdc.RemoveAll()  # clear all objects (icon buttons)
+        # clear everything
+        self.pdc.Clear()
+        self.pdc.RemoveAll()
+        # set font size
+        self.setFontSize(self.fontBaseSize // self.dpi, self.pdc)
+
+        # update rects with which to layout
+        self.updateLayoutRects()
+        # # if debugging, draw all the rects
+        # self.pdc.SetPen(wx.Pen("Red"))
+        # for rect in self.rects.values():
+        #     self.pdc.DrawRectangle(rect)
 
         self.SetBackgroundColour(colors.app['tab_bg'])
-        # work out where the component names and icons should be from name
-        # lengths
-        self.setFontSize(self.fontBaseSize // self.dpi, self.pdc)
-        longest = 0
-        w = 50
-        for comp in self.routine:
-            name = comp.params['name'].val
-            if len(name) > longest:
-                longest = len(name)
-                w = self.GetFullTextExtent(name)[0]
-        self.timeXpos = w + (50, 50, 90)[self.drawSize]
 
         # separate components according to whether they are drawn in separate
         # row
@@ -1942,8 +1943,8 @@ def redrawRoutine(self):
         settingsBtnExtent = self.drawSettingsBtn(self.pdc, self.routine.settings)
 
         # draw static, time grid, normal (row) comp:
-        yPos = self.yPosTop + settingsBtnExtent.Height
-        yPosBottom = yPos + len(rowComponents) * self.componentStep
+        yPos = self.rects['grid'].Top
+        yPosBottom = self.rects['grid'].Bottom
         # draw any Static Components first (below the grid)
         for component in staticCompons:
             bottom = max(yPosBottom, self.GetSize()[1])
@@ -1961,6 +1962,106 @@ def redrawRoutine(self):
         self.Refresh()  # refresh the visible window after drawing (OnPaint)
         #self.scroller.Resize()
 
+    def updateLayoutRects(self):
+        """
+        Recalculate the positions and sizes of the wx.Rect objects which determine
+        how the canvas is laid out.
+        """
+        self.rects = {}
+        self.setFontSize(self.fontBaseSize // self.dpi, self.pdc)
+
+        # --- Whole area ---
+        canvas = self.rects['canvas'] = wx.Rect(
+            x=15,
+            y=15,
+            width=self.sizePix[0] - 30,
+            height=self.sizePix[1] - 30
+        )
+
+        # --- Time grid ---
+        # note: will be modified as things are added around it
+        grid = self.rects['grid'] = wx.Rect(
+            x=canvas.Left,
+            y=canvas.Top,
+            width=canvas.Width,
+            height=self.componentStep * (len(self.routine) - 1)
+        )
+
+        # --- Top bar ---
+        # this is where the Settings button lives
+        topBar = self.rects['topBar'] = wx.Rect(
+            x=canvas.Left,
+            y=canvas.Top,
+            width=canvas.Width,
+            height=int(self.iconSize/3) + 24
+        )
+        # shift grid down
+        grid.Top += topBar.Height
+
+        # --- Time labels ---
+        # note: will be modified as things are added around it
+        timeLbls = self.rects['timeLbls'] = wx.Rect(
+            x=grid.Left,
+            y=topBar.Bottom,
+            width=grid.Width,
+            height=int(self.componentStep/2)
+        )
+        # shift grid down
+        grid.Top += timeLbls.Height
+
+        # --- Component names ---
+        # get width of component names column
+        compNameWidths = [120]
+        if not prefs.builder['abbreviateLongCompNames']:
+            # get width of longest name if we're not elipsizing
+            for comp in self.routine:
+                w = self.GetFullTextExtent(comp.name)[0] + 12
+                compNameWidths.append(w)
+        componentLabelWidth = max(compNameWidths)
+        # create rect
+        compLbls = self.rects['compLbls'] = wx.Rect(
+            x=canvas.Left,
+            y=grid.Top,
+            width=componentLabelWidth,
+            height=grid.Height
+        )
+        # shift grid and time labels right (and cut to size)
+        grid.Left += compLbls.Width
+        grid.Width -= compLbls.Width
+        timeLbls.Left += compLbls.Width
+        timeLbls.Width -= compLbls.Width
+
+        # --- Component icons ---
+        icons = self.rects['icons'] = wx.Rect(
+            x=compLbls.Right,
+            y=grid.Top,
+            width=self.iconSize + 12,
+            height=grid.Height
+        )
+        # shift grid and time labels right (and cut to size)
+        grid.Left += icons.Width + 12
+        grid.Width -= icons.Width + 12
+        timeLbls.Left += icons.Width + 12
+        timeLbls.Width -= icons.Width + 12
+
+        # --- Time units label ---
+        timeUnitsLbl = self.rects['timeUnitsLbl'] = wx.Rect(
+            x=grid.Right,
+            y=grid.Top,
+            width=self.GetFullTextExtent("t (sec)")[0] + 12,
+            height=int(self.componentStep/2)
+        )
+        # align self by right edge
+        timeUnitsLbl.Left -= timeUnitsLbl.Width
+        # shift grid and time labels left (and cut to size)
+        grid.Width -= timeUnitsLbl.Width
+        timeLbls.Width -= timeUnitsLbl.Width
+
+        # update references from rects
+        self.timeXposStart = grid.Left
+        self.timeXposEnd = grid.Right
+        self.iconXpos = self.rects['icons'].Left
+
     def getMaxTime(self):
         """Return the max time to be drawn in the window
         """
@@ -2032,8 +2133,8 @@ def drawTimeGrid(self, dc, yPosTop, yPosBottom, labelAbove=True):
         self.setFontSize(self.fontBaseSize // self.dpi, dc)
         # y is y-half height of text
         dc.DrawText('t (sec)',
-                    int(xEnd + 5),
-                    yPosTop - self.GetFullTextExtent('t')[1] // 2)
+                    self.rects['timeUnitsLbl'].Left + 6,
+                    self.rects['timeUnitsLbl'].Top)
         # or draw bottom labels only if scrolling is turned on, virtual size >
         # available size?
         if yPosBottom > 300:
@@ -2171,7 +2272,7 @@ def drawComponent(self, dc, component, yPos):
             thisIcon = thisIcon.ConvertToDisabled()
             thisColor = colors.app['rt_comp_disabled']
 
-        dc.DrawBitmap(thisIcon, int(self.iconXpos), int(yPos + iconYOffset), True)
+        dc.DrawBitmap(thisIcon, int(self.iconXpos) + 6, int(yPos + iconYOffset), True)
         fullRect = wx.Rect(
             int(self.iconXpos),
             yPos,
@@ -2181,24 +2282,19 @@ def drawComponent(self, dc, component, yPos):
         self.setFontSize(self.fontBaseSize // self.dpi, dc)
 
         name = component.params['name'].val
+        # elipsize name if it's too long
+        if self.GetFullTextExtent(name)[0] > self.rects['compLbls'].Width:
+            name = name[:6] + "..." + name[-6:]
         # get size based on text
-        w, h = self.GetFullTextExtent(name)[0:2]
-        if w > self.iconXpos - self.dpi // 5:
-            # If width is greater than space available, split word at point calculated by average letter width
-            maxLen = int(
-                (self.iconXpos - self.GetFullTextExtent("...")[0] - self.dpi / 5)
-                / (w / len(name))
-            )
-            splitAt = maxLen // 2
-            name = name[:splitAt] + "..." + name[-splitAt:]
-            w = self.iconXpos - self.dpi // 5
+        w = self.rects['compLbls'].Width
+        h = self.GetFullTextExtent(name)[1]
 
         # draw text
         # + x position of icon (left side)
         # - half width of icon (including whitespace around it)
         # - FULL width of text
         # + slight adjustment for whitespace
-        x = self.iconXpos - thisIcon.GetWidth() / 2 - w + thisIcon.GetWidth() / 3
+        x = self.rects['compLbls'].Right - 6 - self.GetFullTextExtent(name)[0]
         _adjust = (5, 5, -2)[self.drawSize]
         y = yPos + thisIcon.GetHeight() // 2 - h // 2 + _adjust
         dc.DrawText(name, int(x), y)
@@ -2283,11 +2379,12 @@ def drawSettingsBtn(self, dc, component):
         self.setFontSize(fontSize, dc)
         # Calculate extent
         extent = wx.Rect(
-            wx.Point(12, 12),
-            wx.Point(
-                12 + padding + sz + 6 + self.GetTextExtent(lbl)[0] + padding,
-                padding + sz + padding)
+            x=self.rects['topBar'].Left,
+            y=self.rects['topBar'].Top,
+            width=padding + sz + 6 + self.GetTextExtent(lbl)[0] + padding,
+            height=padding + sz + padding
         )
+        extent = extent.CenterIn(self.rects['topBar'], dir=wx.VERTICAL)
         # Get content rect
         rect = wx.Rect(extent.TopLeft, extent.BottomRight)
         rect.Deflate(padding)


Test patch: