1 /**
2 Defines possible size for a plugin.
3 
4 Copyright: Guillaume Piolat 2020.
5 License:   $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0)
6 * Authors:   Guillaume Piolat
7 */
8 module dplug.gui.sizeconstraints;
9 
10 import dplug.core.math;
11 
12 nothrow:
13 @nogc:
14 @safe:
15 
16 
17 /// Build a `SizeConstraints` that describes a fixed UI dimensions, in logical pixels.
18 SizeConstraints makeSizeConstraintsFixed(int width, int height)
19 {
20     float[1] ratio = 1.0f;
21     return makeSizeConstraintsDiscrete(width, height, ratio[]);
22 }
23 
24 /// Build a `SizeConstraints` that describes multiple UI dimensions, in logical pixels.
25 /// Aspect ratio is preserved.
26 ///
27 /// Params:
28 ///     defaultWidth base width in pixels.
29 ///     defaultHeight base height in pixels.
30 ///     availableRatios sorted list of UI scale factors, should contain 1.0f.
31 ///                     That list of scale factors: - must be increasing
32 ///                                                 - must contain 1.0f
33 ///                                                 - all factors must be > 0.0f
34 ///
35 /// Warning: no more than 8 possible scales are possible.
36 SizeConstraints makeSizeConstraintsDiscrete(int defaultWidth, 
37                                             int defaultHeight, 
38                                             scope const(float)[] availableScales)
39 {
40     SizeConstraints sc;
41     sc.type = SizeConstraints.Type.discreteRatio;
42     sc.defaultWidth = defaultWidth;
43     sc.defaultHeight = defaultHeight;
44     assert(availableScales.length <= SizeConstraints.MAX_POSSIBLE_SCALES);
45     int N = cast(int)availableScales.length;
46     sc.numDiscreteScales = N;
47     sc.discreteScales[0..N] = availableScales[0..N];
48     return sc;
49 }
50 
51 /// Build a `SizeConstraints` that describes UI dimensions, in logical pixels.
52 /// Aspect ratio is preserved over a range of continuously possible scale factors.
53 ///
54 /// Params:
55 ///     defaultWidth base width in pixels.
56 ///     defaultHeight base height in pixels.
57 ///     availableRatios sorted list of ratios, should contain 1.0f.
58 SizeConstraints makeSizeConstraintsContinuous(int defaultWidth, 
59                                               int defaultHeight,
60                                               float minScale, 
61                                               float maxScale)
62 {
63     assert(minScale > 0.0f && minScale <= 1.0f);
64     assert(maxScale >= 1.0f);
65     SizeConstraints sc;
66     sc.type = SizeConstraints.Type.continuousRatio;
67     sc.defaultWidth = defaultWidth;
68     sc.defaultHeight = defaultHeight;
69     sc.minScale = minScale;
70     sc.maxScale = maxScale;
71     return sc;
72 }
73 
74 /// Build a `SizeConstraints` that describes a rectangular range of size, in logical pixels.
75 /// All continous sizes are valid within these bounds.
76 /// Aspect ratio is NOT preserved.
77 SizeConstraints makeSizeConstraintsBounds(int minWidth, 
78                                           int minHeight, 
79                                           int maxWidth, 
80                                           int maxHeight,
81                                           int defaultWidth,
82                                           int defaultHeight)
83 {
84     assert(defaultWidth >= minWidth && defaultWidth <= maxWidth);
85     assert(defaultHeight >= minHeight && defaultHeight <= maxHeight);
86 
87     SizeConstraints sc;
88     sc.type = SizeConstraints.Type.rectangularBounds;
89     sc.defaultWidth = defaultWidth;
90     sc.defaultHeight = defaultHeight;
91     sc.minWidth = minWidth;
92     sc.maxWidth = maxWidth;
93     sc.minHeight = minHeight;
94     sc.maxHeight = maxHeight;
95     return sc;
96 }
97 
98 
99 /// Build a `SizeConstraints` that describes several scale factors for X and Y, in logical pixels.
100 /// Aspect ratio is NOT preserved.
101 ///
102 /// Params:
103 ///     defaultWidth base width in pixels.
104 ///     defaultHeight base height in pixels.
105 ///     availableRatiosX sorted list of UI scale factors for the X dimension, should contain 1.0f.
106 ///                      That list of scale factors: - must be increasing
107 ///                                                  - must contain 1.0f
108 ///                                                  - all factors must be > 0.0f
109 ///     availableRatiosY  sorted list of UI scale factors for the Y dimension. Same as above.
110 ///
111 /// Warning: no more than 8 possible scales are possible for each axis.
112 SizeConstraints makeSizeConstraintsDiscreteXY(int defaultWidth, 
113                                               int defaultHeight, 
114                                               const(float)[] availableRatiosX,
115                                               const(float)[] availableRatiosY)
116 {
117     SizeConstraints sc;
118     sc.type = SizeConstraints.Type.discreteRatioXY;
119     sc.defaultWidth = defaultWidth;
120     sc.defaultHeight = defaultHeight;
121     assert(availableRatiosX.length <= SizeConstraints.MAX_POSSIBLE_SCALES);
122     assert(availableRatiosY.length <= SizeConstraints.MAX_POSSIBLE_SCALES);
123 
124     int N = cast(int)availableRatiosX.length;
125     sc.numDiscreteScalesX = N;
126     sc.discreteScalesX[0..N] = availableRatiosX[0..N];
127 
128     N = cast(int)availableRatiosY.length;
129     sc.numDiscreteScalesY = N;
130     sc.discreteScalesY[0..N] = availableRatiosY[0..N];
131     return sc;
132 }
133 
134 
135 /// Describe what size in logical pixels are possible.
136 /// A GUIGraphics is given a `SizeConstraints` in its constructor.
137 struct SizeConstraints
138 {
139 public:
140 nothrow:
141 @nogc:
142 
143     enum Type /// Internal type of size constraint
144     {
145         continuousRatio,       /// Continuous zoom factors, preserve aspect ratio
146         discreteRatio,         /// Discrete zoom factors, preserve aspect ratio (recommended)
147         rectangularBounds,     /// Continuous separate zoom factors for X and Y, given with rectangular bounds.
148         discreteRatioXY,       /// Discrete separate zoom factors for X and Y (recommended)
149     }
150 
151     /// Suggest a valid size for plugin first opening.
152     void suggestDefaultSize(int* width, int* height)
153     {
154         *width = defaultWidth;
155         *height = defaultHeight;
156     }
157 
158     /// Returns `true` if several size are possible.
159     bool isResizable()
160     {
161         final switch(type) with (Type)
162         {
163             case continuousRatio:
164                 return true;
165             case discreteRatio:
166                 return numDiscreteScales > 1;
167             case rectangularBounds: 
168                 return true;
169             case discreteRatioXY: 
170                 return (numDiscreteScalesX > 1) || (numDiscreteScalesY > 1);
171         }
172     }
173 
174     bool canResizeHorizontally()
175     {
176         final switch(type) with (Type)
177         {
178             case continuousRatio:
179             case discreteRatio:
180                 return true;
181             case rectangularBounds: 
182                 return minWidth != maxWidth;
183             case discreteRatioXY: 
184                 return numDiscreteScalesX > 1;
185         }
186     }
187 
188     bool canResizeVertically()
189     {
190         final switch(type) with (Type)
191         {
192             case continuousRatio:
193             case discreteRatio:
194                 return true;
195             case rectangularBounds: 
196                 return minHeight != maxHeight;
197             case discreteRatioXY: 
198                 return numDiscreteScalesY > 1;
199         }
200     }
201 
202     /// Returns `true` if this `SizeConstraints` preserve plugin aspect ratio.
203     bool preserveAspectRatio()
204     {
205         final switch(type) with (Type)
206         {
207             case continuousRatio:
208             case discreteRatio:
209                 return true;
210             case rectangularBounds: 
211             case discreteRatioXY: 
212                 return false;
213         }
214     }
215 
216     /// _Approximate_ aspect ratio fraction that should be preserved on resize.
217     /// Only make sense if `preserveAspectRatio()` yields true.
218     /// Returns: X and Y, represent X/Y fraction.
219     int[2] aspectRatio()
220     {
221         final switch(type) with (Type)
222         {
223             case continuousRatio:
224             case discreteRatio:
225                 return [defaultWidth, defaultHeight];
226             case rectangularBounds: 
227             case discreteRatioXY: 
228                 // doesn't make sense, there is no single aspect ratio for the UI
229                 return [defaultWidth, defaultHeight]; 
230         }
231     }
232 
233     /// Returns `true` if this `SizeConstraints` allows this size.
234     bool isValidSize(int width, int height) @trusted
235     {
236         int validw = width,
237             validh = height;
238         getNearestValidSize(&validw, &validh);
239         return validw == width && validh == height; // if the input size is valid, will return the same
240     }
241 
242     /// Given an input size, get the nearest valid size.
243     void getNearestValidSize(int* inoutWidth, int* inoutHeight)
244     {
245         final switch(type) with (Type)
246         {
247             case continuousRatio:
248                 // find estimate of scale
249                 float scale = 0.5f * (*inoutWidth / (cast(float)defaultWidth) 
250                                       + *inoutHeight / (cast(float)defaultHeight));
251                 if (scale < minScale) scale = minScale;
252                 if (scale > maxScale) scale = maxScale;
253                 *inoutWidth = cast(int)(0.5f + scale * defaultWidth);
254                 *inoutHeight = cast(int)(0.5f + scale * defaultHeight);
255                 break;
256 
257             case discreteRatio:
258             {
259                 float scale = 0.5f * (*inoutWidth / (cast(float)defaultWidth) 
260                                       + *inoutHeight / (cast(float)defaultHeight));
261                 scale = findBestMatchingFloat(scale, discreteScales[0..numDiscreteScales]);
262                 *inoutWidth = cast(int)(0.5f + scale * defaultWidth);
263                 *inoutHeight = cast(int)(0.5f + scale * defaultHeight);
264                 break;
265             }
266 
267             case rectangularBounds: 
268                 alias w = inoutWidth;
269                 alias h = inoutHeight;
270                 if (*w < minWidth)  *w = minWidth;
271                 if (*h < minHeight) *h = minHeight;
272                 if (*w > maxWidth)  *w = maxWidth;
273                 if (*h > maxHeight) *h = maxHeight;
274                 break;
275 
276             case discreteRatioXY:
277             {
278                 float scaleX = (*inoutWidth) / (cast(float)defaultWidth);
279                 float scaleY = (*inoutHeight) / (cast(float)defaultHeight);
280                 scaleX = findBestMatchingFloat(scaleX, discreteScalesX[0..numDiscreteScalesX]);
281                 scaleY = findBestMatchingFloat(scaleY, discreteScalesY[0..numDiscreteScalesY]);
282                 *inoutWidth = cast(int)(0.5f + scaleX * defaultWidth);
283                 *inoutHeight = cast(int)(0.5f + scaleY * defaultHeight);
284             }
285         }
286     }
287 
288     /// Given an input size, get a valid size that is the maximum that would fit inside a `inoutWidth` x `inoutHeight`, but smaller.
289     /// Prefer validity if no smaller size is available.
290     void getMaxSmallerValidSize(int* inoutWidth, int* inoutHeight)
291     {
292         final switch(type) with (Type)
293         {
294             case continuousRatio:
295             {
296                 // find estimate of scale
297                 float scaleX = *inoutWidth / (cast(float)defaultWidth);
298                 float scaleY = *inoutHeight / (cast(float)defaultHeight);
299                 float scale = (scaleX < scaleY) ? scaleX : scaleY;
300                 if (scale < minScale) scale = minScale;
301                 if (scale > maxScale) scale = maxScale;
302                 *inoutWidth = cast(int)(0.5f + scale * defaultWidth);
303                 *inoutHeight = cast(int)(0.5f + scale * defaultHeight);
304                 break;
305             }
306 
307             case discreteRatio:
308             {
309                 // Note: because of ugly rounding issue, we cannot just find the scale from input size.
310                 // See Issue #593. Find the best size by generating the size forward and see which one fits.
311 
312                 int w = 0;
313                 int h = 0;
314 
315                 int bestIndex = 0; // should be the smallest size... not checked
316                 float bestScore = float.infinity;
317                 for (int n = 0; n < numDiscreteScales; ++n)
318                 {
319                     // Generate a possible size.
320                     int cand_w = cast(int)(0.5f + discreteScales[n] * defaultWidth);
321                     int cand_h = cast(int)(0.5f + discreteScales[n] * defaultHeight);
322 
323                     float scoreX = (*inoutWidth - cand_w);
324                     float scoreY = (*inoutHeight - cand_h);
325                     float score = scoreX + scoreY;
326                     if ( (scoreX >= 0) && (scoreY >= 0) && (score < bestScore) )
327                     {
328                         bestScore = score;
329                         bestIndex = n;
330                     }
331                 }
332 
333                 *inoutWidth = cast(int)(0.5f + discreteScales[bestIndex] * defaultWidth);
334                 *inoutHeight = cast(int)(0.5f + discreteScales[bestIndex] * defaultHeight);
335                 break;
336             }
337 
338             case rectangularBounds: 
339                 alias w = inoutWidth;
340                 alias h = inoutHeight;
341                 if (*w < minWidth)  *w = minWidth;
342                 if (*h < minHeight) *h = minHeight;
343                 if (*w > maxWidth)  *w = maxWidth;
344                 if (*h > maxHeight) *h = maxHeight;
345                 break;
346 
347             case discreteRatioXY:
348             {
349                 // +0.5f since a smaller ratio would lead to a smaller size being generated
350                 float scaleX = (*inoutWidth + 0.5f) / (cast(float)defaultWidth);
351                 float scaleY = (*inoutHeight + 0.5f) / (cast(float)defaultHeight);
352                 scaleX = findMinMatchingFloat(scaleX, discreteScalesX[0..numDiscreteScalesX]);
353                 scaleY = findMinMatchingFloat(scaleY, discreteScalesY[0..numDiscreteScalesY]);
354                 *inoutWidth = cast(int)(0.5f + scaleX * defaultWidth);
355                 *inoutHeight = cast(int)(0.5f + scaleY * defaultHeight);
356             }
357         }
358     }
359 
360 private:
361 
362     enum MAX_POSSIBLE_SCALES = 12;
363 
364     Type type;
365 
366     int defaultWidth;
367     int defaultHeight;
368     
369     int numDiscreteScales = 0;
370     float[MAX_POSSIBLE_SCALES] discreteScales; // only used with discreteRatio or rectangularBoundsDiscrete case
371 
372     alias numDiscreteScalesX = numDiscreteScales;
373     alias discreteScalesX = discreteScales;
374     int numDiscreteScalesY = 0;
375     float[MAX_POSSIBLE_SCALES] discreteScalesY; // only used with rectangularBoundsDiscrete case
376 
377     float minScale, maxScale;          // only used with continuousRatio case
378 
379     int minWidth;                      // only used in rectangularBounds case
380     int minHeight;
381     int maxWidth;
382     int maxHeight;
383 }
384 
385 private:
386 
387 // Return arr[n], the nearest element of the array to x
388 static float findBestMatchingFloat(float x, const(float)[] arr) pure @trusted
389 {
390     assert(arr.length > 0);
391     float bestScore = -float.infinity;
392     int bestIndex = 0;
393     for (int n = 0; n < cast(int)arr.length; ++n)
394     {
395         float score = -fast_fabs(arr[n] - x);
396         if (score > bestScore)
397         {
398             bestScore = score;
399             bestIndex = n;
400         }
401     }
402     return arr[bestIndex];
403 }
404 
405 // Return arr[n], the element of the array that approach `threshold` better without exceeding it
406 // (unless every proposed item exceed)
407 static float findMinMatchingFloat(float threshold, const(float)[] arr) pure @trusted
408 {
409     assert(arr.length > 0);
410     float bestScore = float.infinity;
411     int bestIndex = 0;
412     for (int n = 0; n < cast(int)arr.length; ++n)
413     {
414         float score = (threshold - arr[n]);
415         if ( (score >= 0) && (score < bestScore) )
416         {
417             bestScore = score;
418             bestIndex = n;
419         }
420     }
421 
422     // All items were above the threshold, use nearest item.
423     if (bestIndex == -1)
424         return findBestMatchingFloat(threshold, arr);
425 
426     return arr[bestIndex];
427 }
428 
429 @trusted unittest
430 {
431     int w, h;
432 
433     SizeConstraints a, b;
434     a = makeSizeConstraintsFixed(640, 480);
435     b = a;
436 
437     float[3] ratios = [0.5f, 1.0f, 2.0f];
438     SizeConstraints c = makeSizeConstraintsDiscrete(640, 480, ratios[]);
439     assert(c.isValidSize(640, 480));
440 
441     w = 640*2-1;
442     h = 480-1;
443     c.getMaxSmallerValidSize(&w, &h);
444     assert(w == 320 && h == 240);
445 
446     w = 640-1;
447     h = 480;
448     c.getMaxSmallerValidSize(&w, &h);
449     assert(w == 320 && h == 240);
450 
451     c = makeSizeConstraintsContinuous(640, 480, 0.5f, 2.0f);
452     assert(c.isValidSize(640, 480));
453     assert(!c.isValidSize(640/4, 480/4));
454     assert(c.isValidSize(640/2, 480/2));
455     assert(c.isValidSize(640*2, 480*2));
456     
457     a.suggestDefaultSize(&w, &h);
458     assert(w == 640 && h == 480);
459 
460     float[3] ratiosX = [0.5f, 1.0f, 2.0f];
461     float[4] ratiosY = [0.5f, 1.0f, 2.0f, 3.0f];
462     c = makeSizeConstraintsDiscreteXY(900, 500, ratiosX[], ratiosY[]);
463     c.suggestDefaultSize(&w, &h);
464     assert(w == 900 && h == 500);
465 
466     w = 100; h = 501;
467     c.getNearestValidSize(&w, &h);
468     assert(w == 450 && h == 500);
469 
470     w = 1000; h = 2500;
471     c.getNearestValidSize(&w, &h);
472     assert(w == 900 && h == 1500);
473 }
474 
475 unittest
476 {
477     float[4] A = [1.0f, 2, 3, 4];
478     assert( findMinMatchingFloat(3.8f, A) == 3 );
479     assert( findMinMatchingFloat(10.0f, A) == 4 );
480     assert( findMinMatchingFloat(2.0f, A) == 2 );
481     assert( findMinMatchingFloat(-1.0f, A) == 1 );
482     assert( findBestMatchingFloat(3.8f, A) == 4 );
483     assert( findBestMatchingFloat(10.0f, A) == 4 );
484     assert( findBestMatchingFloat(2.0f, A) == 2 );
485     assert( findBestMatchingFloat(-1.0f, A) == 1 );
486 }
487 
488 // Issue #593, max min valid size not matching
489 @trusted unittest
490 {
491     static immutable float[6] ratios = [0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f];
492     SizeConstraints c = makeSizeConstraintsDiscrete(626, 487, ratios);
493     int w = 1096, h = 852;
494     c.getMaxSmallerValidSize(&w, &h);
495     assert(w == 1096 && h == 852);
496 
497     // Same but with separate XY
498     c = makeSizeConstraintsDiscreteXY(487, 487, ratios, ratios);
499     w = 852;
500     h = 852;
501     c.getMaxSmallerValidSize(&w, &h);
502     assert(w == 852 && h == 852);
503 }