|
42 | 42 | ' widths plus this gutter so an open menu never spans more than two columns of |
43 | 43 | ' horizontal real estate. |
44 | 44 | const TRACK_DROPDOWN_INTER_SLOT_GUTTER = 30 |
45 | | -' Multiplier applied to the TextSizeTask measurement when computing menu width. |
46 | | -' GetOneLineWidth returns the font-metric width which can come up a few pixels |
47 | | -' short of how the actual ScrollingLabel renders. The gap varies per string — |
48 | | -' caps-heavy strings with numbers ("ENGLISH TRUEHD 7.1") drift only ~2-3px while |
49 | | -' mixed-case strings with punctuation ("Spanish; Castilian SUBRIP") can drift |
50 | | -' more — so a flat fudge constant is the wrong shape: it either over-pads short |
51 | | -' strings or under-pads long ones. Scaling proportionally to the measured width |
52 | | -' tracks the actual risk: short strings get a small buffer (no wasted space), |
53 | | -' long strings get a larger buffer (no clipping). 3% is the lowest stable value |
54 | | -' on tested hardware; bump to 1.04 / 1.05 if any title ever clips again. |
55 | | -const TRACK_DROPDOWN_TEXT_WIDTH_MULTIPLIER = 1.03 |
56 | 45 |
|
57 | 46 | sub init() |
58 | 47 | m.log = new log.Logger("TrackDropdown") |
|
71 | 60 | m.visibleRows = 5 |
72 | 61 | m.rowHeight = 0 ' computed in onItemsChanged |
73 | 62 |
|
74 | | - ' TextSizeTask is required because roFontRegistry / Font.GetOneLineWidth are |
75 | | - ' MAIN|TASK-only — they cannot be created on the render thread where component |
76 | | - ' field observers run. The task receives titles + font size, returns a width |
77 | | - ' array we observe on the "width" field. See JRButtons.bs for the same pattern. |
78 | | - m.textSizeTask = CreateObject("roSGNode", "TextSizeTask") |
79 | | - m.textSizeTask.observeField("width", "onTextSizeTaskComplete") |
| 63 | + ' Hidden Label used to measure each item's natural pixel width before we size the |
| 64 | + ' menu. We use a themed LabelPrimarySmaller — same theme as the rendered row's |
| 65 | + ' ScrollingLabel — so font URI + fontSizeSmaller + uiFontFallback scaling come |
| 66 | + ' for free via LabelSmaller.init / JRLabel.init. Same trick FontScalingTask uses |
| 67 | + ' against scene-level defaultFont/fallbackFont nodes (see FontScalingTask.bs). |
| 68 | + m.measureLabel = m.top.findNode("measureLabel") |
80 | 69 |
|
81 | 70 | m.top.observeField("focusedChild", "onFocusChanged") |
82 | 71 |
|
|
165 | 154 | ' duplication read as "your current value, persistent above and inside the menu" |
166 | 155 | ' rather than competing with the alternatives. See TrackDropdownRow.onFocusStateChanged. |
167 | 156 | sub onItemsChanged() |
168 | | - ' Clear existing items |
169 | 157 | m.menuItems.removeChildren(m.menuItems.getChildren(-1, 0)) |
170 | 158 | m.menuItems.translation = [0, 0] |
171 | 159 | m.focusedItemIndex = 0 |
|
178 | 166 | end if |
179 | 167 |
|
180 | 168 | constants = m.global.constants |
181 | | - slotWidth = m.top.slotWidth |
| 169 | + slotWidth = int(m.top.slotWidth) |
182 | 170 |
|
183 | 171 | ' Row height matches trigger font (fontSizeSmaller 25) with breathing padding so |
184 | 172 | ' focus border doesn't crowd the text. Mirrors JRDropdown's formula structure |
185 | 173 | ' (line height + padding) scaled for the smaller font. |
186 | 174 | m.rowHeight = int(constants.fontSizeSmaller * 1.0) + 20 |
187 | 175 |
|
188 | | - ' Initial menu width = slotWidth. The async TextSizeTask measures each title and |
189 | | - ' may widen the menu up to 2 slot widths + the inter-slot gutter if titles are |
190 | | - ' long. In normal usage the task completes well before the user opens the menu, |
191 | | - ' so the user sees the auto-sized width — not the slotWidth fallback. If the |
192 | | - ' task hasn't completed (or doesn't run, e.g. in unit-test environment), the |
193 | | - ' menu still renders correctly at slotWidth, just without the long-title fit. |
194 | | - initialWidth = slotWidth |
| 176 | + menuWidth = computeMenuWidth(items, slotWidth) |
195 | 177 |
|
196 | 178 | for each item in items |
197 | 179 | menuItem = CreateObject("roSGNode", "TrackDropdownRow") |
198 | 180 | menuItem.text = item.title |
199 | 181 | menuItem.itemId = item.id |
200 | | - menuItem.itemWidth = initialWidth |
| 182 | + menuItem.itemWidth = menuWidth |
201 | 183 | menuItem.itemHeight = m.rowHeight |
202 | 184 | m.menuItems.appendChild(menuItem) |
203 | 185 | end for |
204 | 186 |
|
205 | 187 | visibleRows = m.visibleRows |
206 | 188 | if items.count() < visibleRows then visibleRows = items.count() |
207 | | - |
208 | 189 | viewportHeight = m.rowHeight * visibleRows |
| 190 | + |
209 | 191 | ' clippingRect is in the viewport's local coords: [x, y, width, height]. Anything the |
210 | 192 | ' menuItems LayoutGroup draws outside this rect is invisible, enabling the scroll UX. |
211 | | - m.menuViewport.clippingRect = [0, 0, initialWidth, viewportHeight] |
212 | | - |
213 | | - m.menuBackground.width = initialWidth |
| 193 | + m.menuViewport.clippingRect = [0, 0, menuWidth, viewportHeight] |
| 194 | + m.menuBackground.width = menuWidth |
214 | 195 | m.menuBackground.height = viewportHeight |
215 | 196 |
|
216 | 197 | if isValidAndNotEmpty(m.top.selectedItemId) |
217 | 198 | onSelectedItemChanged() |
218 | 199 | end if |
219 | | - |
220 | | - ' Kick off async measurement to refine width once the task returns. |
221 | | - measureMenuWidthAsync(items) |
222 | | -end sub |
223 | | - |
224 | | -' measureMenuWidthAsync: Pushes the item titles through TextSizeTask so the task |
225 | | -' can call roFontRegistry / GetOneLineWidth on a non-render thread (the registry |
226 | | -' is MAIN|TASK-only). Result is delivered via the "width" observer below. |
227 | | -sub measureMenuWidthAsync(items as object) |
228 | | - if not isValid(m.textSizeTask) then return |
229 | | - ' Cancel any in-flight measurement so an older items[] response can't overwrite |
230 | | - ' a newer one. |
231 | | - m.textSizeTask.control = "STOP" |
232 | | - |
233 | | - titles = [] |
234 | | - for each item in items |
235 | | - if isValid(item.title) then titles.push(item.title) |
236 | | - end for |
237 | | - if titles.count() = 0 then return |
238 | | - |
239 | | - m.textSizeTask.fontsize = m.global.constants.fontSizeSmaller |
240 | | - ' Leave maxWidth at its XML default (1920 = full screen width). 1920 is wider |
241 | | - ' than any realistic track title at fontSizeSmaller so GetOneLineWidth returns |
242 | | - ' natural unwrapped widths, and we avoid passing an unusually large value that |
243 | | - ' could perturb the font-metric calculation. |
244 | | - m.textSizeTask.text = titles |
245 | | - m.textSizeTask.control = "RUN" |
246 | 200 | end sub |
247 | 201 |
|
248 | | -' onTextSizeTaskComplete: Reads the per-title widths produced by TextSizeTask, |
249 | | -' picks the maximum, adds row padding, and clamps to [slotWidth, slotWidth*2 + |
250 | | -' interSlotGutter]. The lower clamp keeps the menu anchored under its trigger |
251 | | -' column; the upper clamp prevents an open menu from spanning more than two |
252 | | -' columns of horizontal real estate. Then resizes menuBackground, the clipping |
253 | | -' viewport, and every row in lockstep. |
254 | | -sub onTextSizeTaskComplete() |
255 | | - widths = m.textSizeTask.width |
256 | | - if not isValid(widths) or widths.count() = 0 then return |
257 | | - |
258 | | - ' Reject stale results: when the user changes the video source we STOP+RUN the |
259 | | - ' task with new titles, but a result from the previous run can still land |
260 | | - ' (control=STOP doesn't interrupt a synchronous getTextSize() in progress). If |
261 | | - ' the menu's current row count no longer matches the measured count, the items |
262 | | - ' array changed mid-flight — drop this result and let the in-flight task's |
263 | | - ' completion apply the up-to-date measurements. |
264 | | - if widths.count() <> m.menuItems.getChildCount() then return |
265 | | - |
| 202 | +' Returns the menu width that fits the longest item title, clamped to |
| 203 | +' [slotWidth, slotWidth*2 + interSlotGutter]. The lower clamp keeps the menu |
| 204 | +' anchored under its trigger column; the upper clamp stops an open menu from |
| 205 | +' spanning more than two columns of horizontal real estate. |
| 206 | +' |
| 207 | +' Measurement is pixel-exact: m.measureLabel is a themed LabelPrimarySmaller, so |
| 208 | +' setting its text and reading boundingRect().width returns the same width the |
| 209 | +' rendered row's ScrollingLabel will use (same font, same fontSizeSmaller, same |
| 210 | +' uiFontFallback scale factor). No fudge multiplier needed. Same render-thread |
| 211 | +' boundingRect technique FontScalingTask uses against the scene's defaultFont / |
| 212 | +' fallbackFont labels. |
| 213 | +function computeMenuWidth(items as object, slotWidth as integer) as integer |
266 | 214 | maxTextWidth = 0 |
267 | | - for each w in widths |
268 | | - iw = int(w) |
269 | | - if iw > maxTextWidth then maxTextWidth = iw |
| 215 | + for each item in items |
| 216 | + if isValid(item.title) |
| 217 | + m.measureLabel.text = item.title |
| 218 | + w = int(m.measureLabel.boundingRect().width) |
| 219 | + if w > maxTextWidth then maxTextWidth = w |
| 220 | + end if |
270 | 221 | end for |
271 | 222 |
|
272 | | - ' Scale the measured width by the multiplier to compensate for the per-string |
273 | | - ' render-vs-metric gap (see TRACK_DROPDOWN_TEXT_WIDTH_MULTIPLIER comment for |
274 | | - ' rationale), then add row padding for the menu's inner edges. |
275 | | - desired = int(maxTextWidth * TRACK_DROPDOWN_TEXT_WIDTH_MULTIPLIER) + (TRACK_DROPDOWN_ROW_H_PADDING * 2) |
276 | | - ' m.top.slotWidth is dynamic (node field); coerce to int so 'desired' stays |
277 | | - ' integer-typed across the clamp comparisons. |
278 | | - slotWidth = int(m.top.slotWidth) |
| 223 | + desired = maxTextWidth + (TRACK_DROPDOWN_ROW_H_PADDING * 2) |
279 | 224 | if desired < slotWidth then desired = slotWidth |
280 | 225 | upperBound = (slotWidth * 2) + TRACK_DROPDOWN_INTER_SLOT_GUTTER |
281 | 226 | if desired > upperBound then desired = upperBound |
282 | | - |
283 | | - applyMenuWidth(desired) |
284 | | -end sub |
285 | | - |
286 | | -' applyMenuWidth: Resizes the menu in lockstep — background + clipping viewport + |
287 | | -' every row's itemWidth. Called from onTextSizeTaskComplete; no-op if the menu |
288 | | -' has been torn down between task launch and task completion. |
289 | | -sub applyMenuWidth(width as integer) |
290 | | - if not isValid(m.menuItems) then return |
291 | | - rowCount = m.menuItems.getChildCount() |
292 | | - if rowCount = 0 then return |
293 | | - |
294 | | - visibleRows = m.visibleRows |
295 | | - if rowCount < visibleRows then visibleRows = rowCount |
296 | | - viewportHeight = m.rowHeight * visibleRows |
297 | | - |
298 | | - m.menuViewport.clippingRect = [0, 0, width, viewportHeight] |
299 | | - m.menuBackground.width = width |
300 | | - for i = 0 to rowCount - 1 |
301 | | - m.menuItems.getChild(i).itemWidth = width |
302 | | - end for |
303 | | -end sub |
| 227 | + return desired |
| 228 | +end function |
304 | 229 |
|
305 | 230 | sub onSelectedItemChanged() |
306 | 231 | selectedId = m.top.selectedItemId |
|
606 | 531 |
|
607 | 532 | m.menuItems.removeChildren(m.menuItems.getChildren(-1, 0)) |
608 | 533 |
|
609 | | - if isValid(m.textSizeTask) |
610 | | - m.textSizeTask.unobserveField("width") |
611 | | - m.textSizeTask.control = "STOP" |
612 | | - m.textSizeTask = invalid |
613 | | - end if |
614 | | - |
615 | 534 | m.buttonBackground = invalid |
616 | 535 | m.buttonBorder = invalid |
617 | 536 | m.triggerLabel = invalid |
618 | 537 | m.triggerChevron = invalid |
| 538 | + m.measureLabel = invalid |
619 | 539 | m.menuContainer = invalid |
620 | 540 | m.menuBackground = invalid |
621 | 541 | m.menuViewport = invalid |
|
0 commit comments