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     /// Returns `true` if this `SizeConstraints` preserve plugin aspect ratio.
175     bool preserveAspectRatio()
176     {
177         final switch(type) with (Type)
178         {
179             case continuousRatio:
180             case discreteRatio:
181                 return true;
182             case rectangularBounds: 
183             case discreteRatioXY: 
184                 return false;
185         }
186     }
187 
188     /// Returns `true` if this `SizeConstraints` allows this size.
189     bool isValidSize(int width, int height) @trusted
190     {
191         int validw = width,
192             validh = height;
193         getNearestValidSize(&validw, &validh);
194         return validw == width && validh == height; // if the input size is valid, will return the same
195     }
196 
197     /// Given an input size, get the nearest valid size.
198     void getNearestValidSize(int* inoutWidth, int* inoutHeight)
199     {
200         final switch(type) with (Type)
201         {
202             case continuousRatio:
203                 // find estimate of scale
204                 float scale = 0.5f * (*inoutWidth / (cast(float)defaultWidth) 
205                                       + *inoutHeight / (cast(float)defaultHeight));
206                 if (scale < minScale) scale = minScale;
207                 if (scale > maxScale) scale = maxScale;
208                 *inoutWidth = cast(int)(0.5f + scale * defaultWidth);
209                 *inoutHeight = cast(int)(0.5f + scale * defaultHeight);
210                 break;
211 
212             case discreteRatio:
213             {
214                 float scale = 0.5f * (*inoutWidth / (cast(float)defaultWidth) 
215                                       + *inoutHeight / (cast(float)defaultHeight));
216                 scale = findBestMatchingFloat(scale, discreteScales[0..numDiscreteScales]);
217                 *inoutWidth = cast(int)(0.5f + scale * defaultWidth);
218                 *inoutHeight = cast(int)(0.5f + scale * defaultHeight);
219                 break;
220             }
221 
222             case rectangularBounds: 
223                 alias w = inoutWidth;
224                 alias h = inoutHeight;
225                 if (*w < minWidth)  *w = minWidth;
226                 if (*h < minHeight) *h = minHeight;
227                 if (*w > maxWidth)  *w = maxWidth;
228                 if (*h > maxHeight) *h = maxHeight;
229                 break;
230 
231             case discreteRatioXY:
232             {
233                 float scaleX = (*inoutWidth) / (cast(float)defaultWidth);
234                 float scaleY = (*inoutHeight) / (cast(float)defaultHeight);
235                 scaleX = findBestMatchingFloat(scaleX, discreteScalesX[0..numDiscreteScalesX]);
236                 scaleY = findBestMatchingFloat(scaleY, discreteScalesY[0..numDiscreteScalesY]);
237                 *inoutWidth = cast(int)(0.5f + scaleX * defaultWidth);
238                 *inoutHeight = cast(int)(0.5f + scaleY * defaultHeight);
239             }
240         }
241     }
242 
243     /// Given an input size, get a valid size that is the maximum that would fit inside a `inoutWidth` x `inoutHeight`, but smaller.
244     /// Prefer validity if no smaller size is available.
245     void getMaxSmallerValidSize(int* inoutWidth, int* inoutHeight)
246     {
247         final switch(type) with (Type)
248         {
249             case continuousRatio:
250             {
251                 // find estimate of scale
252                 float scaleX = *inoutWidth / (cast(float)defaultWidth);
253                 float scaleY = *inoutHeight / (cast(float)defaultHeight);
254                 float scale = (scaleX < scaleY) ? scaleX : scaleY;
255                 if (scale < minScale) scale = minScale;
256                 if (scale > maxScale) scale = maxScale;
257                 *inoutWidth = cast(int)(0.5f + scale * defaultWidth);
258                 *inoutHeight = cast(int)(0.5f + scale * defaultHeight);
259                 break;
260             }
261 
262             case discreteRatio:
263             {
264                 // Note: because of ugly rounding issue, we cannot just find the scale from input size.
265                 // See Issue #593. Find the best size by generating the size forward and see which one fits.
266 
267                 int w = 0;
268                 int h = 0;
269 
270                 int bestIndex = 0; // should be the smallest size... not checked
271                 float bestScore = float.infinity;
272                 for (int n = 0; n < numDiscreteScales; ++n)
273                 {
274                     // Generate a possible size.
275                     int cand_w = cast(int)(0.5f + discreteScales[n] * defaultWidth);
276                     int cand_h = cast(int)(0.5f + discreteScales[n] * defaultHeight);
277 
278                     float scoreX = (*inoutWidth - cand_w);
279                     float scoreY = (*inoutHeight - cand_h);
280                     float score = scoreX + scoreY;
281                     if ( (scoreX >= 0) && (scoreY >= 0) && (score < bestScore) )
282                     {
283                         bestScore = score;
284                         bestIndex = n;
285                     }
286                 }
287 
288                 *inoutWidth = cast(int)(0.5f + discreteScales[bestIndex] * defaultWidth);
289                 *inoutHeight = cast(int)(0.5f + discreteScales[bestIndex] * defaultHeight);
290                 break;
291             }
292 
293             case rectangularBounds: 
294                 alias w = inoutWidth;
295                 alias h = inoutHeight;
296                 if (*w < minWidth)  *w = minWidth;
297                 if (*h < minHeight) *h = minHeight;
298                 if (*w > maxWidth)  *w = maxWidth;
299                 if (*h > maxHeight) *h = maxHeight;
300                 break;
301 
302             case discreteRatioXY:
303             {
304                 // +0.5f since a smaller ratio would lead to a smaller size being generated
305                 float scaleX = (*inoutWidth + 0.5f) / (cast(float)defaultWidth);
306                 float scaleY = (*inoutHeight + 0.5f) / (cast(float)defaultHeight);
307                 scaleX = findMinMatchingFloat(scaleX, discreteScalesX[0..numDiscreteScalesX]);
308                 scaleY = findMinMatchingFloat(scaleY, discreteScalesY[0..numDiscreteScalesY]);
309                 *inoutWidth = cast(int)(0.5f + scaleX * defaultWidth);
310                 *inoutHeight = cast(int)(0.5f + scaleY * defaultHeight);
311             }
312         }
313     }
314 
315 private:
316 
317     enum MAX_POSSIBLE_SCALES = 12;
318 
319     Type type;
320 
321     int defaultWidth;
322     int defaultHeight;
323     
324     int numDiscreteScales = 0;
325     float[MAX_POSSIBLE_SCALES] discreteScales; // only used with discreteRatio or rectangularBoundsDiscrete case
326 
327     alias numDiscreteScalesX = numDiscreteScales;
328     alias discreteScalesX = discreteScales;
329     int numDiscreteScalesY = 0;
330     float[MAX_POSSIBLE_SCALES] discreteScalesY; // only used with rectangularBoundsDiscrete case
331 
332     float minScale, maxScale;          // only used with continuousRatio case
333 
334     int minWidth;                      // only used in rectangularBounds case
335     int minHeight;
336     int maxWidth;
337     int maxHeight;
338 }
339 
340 private:
341 
342 // Return arr[n], the nearest element of the array to x
343 static float findBestMatchingFloat(float x, const(float)[] arr) pure @trusted
344 {
345     assert(arr.length > 0);
346     float bestScore = -float.infinity;
347     int bestIndex = 0;
348     for (int n = 0; n < cast(int)arr.length; ++n)
349     {
350         float score = -fast_fabs(arr[n] - x);
351         if (score > bestScore)
352         {
353             bestScore = score;
354             bestIndex = n;
355         }
356     }
357     return arr[bestIndex];
358 }
359 
360 // Return arr[n], the element of the array that approach `threshold` better without exceeding it
361 // (unless every proposed item exceed)
362 static float findMinMatchingFloat(float threshold, const(float)[] arr) pure @trusted
363 {
364     assert(arr.length > 0);
365     float bestScore = float.infinity;
366     int bestIndex = 0;
367     for (int n = 0; n < cast(int)arr.length; ++n)
368     {
369         float score = (threshold - arr[n]);
370         if ( (score >= 0) && (score < bestScore) )
371         {
372             bestScore = score;
373             bestIndex = n;
374         }
375     }
376 
377     // All items were above the threshold, use nearest item.
378     if (bestIndex == -1)
379         return findBestMatchingFloat(threshold, arr);
380 
381     return arr[bestIndex];
382 }
383 
384 @trusted unittest
385 {
386     int w, h;
387 
388     SizeConstraints a, b;
389     a = makeSizeConstraintsFixed(640, 480);
390     b = a;
391 
392     float[3] ratios = [0.5f, 1.0f, 2.0f];
393     SizeConstraints c = makeSizeConstraintsDiscrete(640, 480, ratios[]);
394     assert(c.isValidSize(640, 480));
395 
396     w = 640*2-1;
397     h = 480-1;
398     c.getMaxSmallerValidSize(&w, &h);
399     assert(w == 320 && h == 240);
400 
401     w = 640-1;
402     h = 480;
403     c.getMaxSmallerValidSize(&w, &h);
404     assert(w == 320 && h == 240);
405 
406     c = makeSizeConstraintsContinuous(640, 480, 0.5f, 2.0f);
407     assert(c.isValidSize(640, 480));
408     assert(!c.isValidSize(640/4, 480/4));
409     assert(c.isValidSize(640/2, 480/2));
410     assert(c.isValidSize(640*2, 480*2));
411     
412     a.suggestDefaultSize(&w, &h);
413     assert(w == 640 && h == 480);
414 
415     float[3] ratiosX = [0.5f, 1.0f, 2.0f];
416     float[4] ratiosY = [0.5f, 1.0f, 2.0f, 3.0f];
417     c = makeSizeConstraintsDiscreteXY(900, 500, ratiosX[], ratiosY[]);
418     c.suggestDefaultSize(&w, &h);
419     assert(w == 900 && h == 500);
420 
421     w = 100; h = 501;
422     c.getNearestValidSize(&w, &h);
423     assert(w == 450 && h == 500);
424 
425     w = 1000; h = 2500;
426     c.getNearestValidSize(&w, &h);
427     assert(w == 900 && h == 1500);
428 }
429 
430 unittest
431 {
432     float[4] A = [1.0f, 2, 3, 4];
433     assert( findMinMatchingFloat(3.8f, A) == 3 );
434     assert( findMinMatchingFloat(10.0f, A) == 4 );
435     assert( findMinMatchingFloat(2.0f, A) == 2 );
436     assert( findMinMatchingFloat(-1.0f, A) == 1 );
437     assert( findBestMatchingFloat(3.8f, A) == 4 );
438     assert( findBestMatchingFloat(10.0f, A) == 4 );
439     assert( findBestMatchingFloat(2.0f, A) == 2 );
440     assert( findBestMatchingFloat(-1.0f, A) == 1 );
441 }
442 
443 // Issue #593, max min valid size not matching
444 @trusted unittest
445 {
446     static immutable float[6] ratios = [0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f];
447     SizeConstraints c = makeSizeConstraintsDiscrete(626, 487, ratios);
448     int w = 1096, h = 852;
449     c.getMaxSmallerValidSize(&w, &h);
450     assert(w == 1096 && h == 852);
451 
452     // Same but with separate XY
453     c = makeSizeConstraintsDiscreteXY(487, 487, ratios, ratios);
454     w = 852;
455     h = 852;
456     c.getMaxSmallerValidSize(&w, &h);
457     assert(w == 852 && h == 852);
458 }