#36 scaling support for TImage
[fanfix.git] / src / jexer / TImage.java
1 /*
2 * Jexer - Java Text User Interface
3 *
4 * The MIT License (MIT)
5 *
6 * Copyright (C) 2019 Kevin Lamonte
7 *
8 * Permission is hereby granted, free of charge, to any person obtaining a
9 * copy of this software and associated documentation files (the "Software"),
10 * to deal in the Software without restriction, including without limitation
11 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
12 * and/or sell copies of the Software, and to permit persons to whom the
13 * Software is furnished to do so, subject to the following conditions:
14 *
15 * The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
17 *
18 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
21 * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
24 * DEALINGS IN THE SOFTWARE.
25 *
26 * @author Kevin Lamonte [kevin.lamonte@gmail.com]
27 * @version 1
28 */
29 package jexer;
30
31 import java.awt.image.BufferedImage;
32
33 import jexer.backend.ECMA48Terminal;
34 import jexer.backend.MultiScreen;
35 import jexer.backend.SwingTerminal;
36 import jexer.bits.Cell;
37 import jexer.event.TKeypressEvent;
38 import jexer.event.TMouseEvent;
39 import jexer.event.TResizeEvent;
40 import static jexer.TKeypress.*;
41
42 /**
43 * TImage renders a piece of a bitmap image on screen.
44 */
45 public class TImage extends TWidget {
46
47 // ------------------------------------------------------------------------
48 // Constants --------------------------------------------------------------
49 // ------------------------------------------------------------------------
50
51 /**
52 * Selections for fitting the image to the text cells.
53 */
54 public enum Scale {
55 /**
56 * No scaling.
57 */
58 NONE,
59
60 /**
61 * Stretch/shrink the image in both directions to fully fill the text
62 * area width/height.
63 */
64 STRETCH,
65
66 /**
67 * Scale the image, preserving aspect ratio, to fill the text area
68 * width/height (like letterbox). The background color for the
69 * letterboxed area is specified in scaleBackColor.
70 */
71 SCALE,
72 }
73
74 // ------------------------------------------------------------------------
75 // Variables --------------------------------------------------------------
76 // ------------------------------------------------------------------------
77
78 /**
79 * Scaling strategy to use.
80 */
81 private Scale scale = Scale.NONE;
82
83 /**
84 * Scaling strategy to use.
85 */
86 private java.awt.Color scaleBackColor = java.awt.Color.BLACK;
87
88 /**
89 * The action to perform when the user clicks on the image.
90 */
91 private TAction clickAction;
92
93 /**
94 * The image to display.
95 */
96 private BufferedImage image;
97
98 /**
99 * The original image from construction time.
100 */
101 private BufferedImage originalImage;
102
103 /**
104 * The current scaling factor for the image.
105 */
106 private double scaleFactor = 1.0;
107
108 /**
109 * The current clockwise rotation for the image.
110 */
111 private int clockwise = 0;
112
113 /**
114 * If true, this widget was resized and a new scaled image must be
115 * produced.
116 */
117 private boolean resized = false;
118
119 /**
120 * Left column of the image. 0 is the left-most column.
121 */
122 private int left;
123
124 /**
125 * Top row of the image. 0 is the top-most row.
126 */
127 private int top;
128
129 /**
130 * The cells containing the broken up image pieces.
131 */
132 private Cell cells[][];
133
134 /**
135 * The number of rows in cells[].
136 */
137 private int cellRows;
138
139 /**
140 * The number of columns in cells[].
141 */
142 private int cellColumns;
143
144 /**
145 * Last text width value.
146 */
147 private int lastTextWidth = -1;
148
149 /**
150 * Last text height value.
151 */
152 private int lastTextHeight = -1;
153
154 // ------------------------------------------------------------------------
155 // Constructors -----------------------------------------------------------
156 // ------------------------------------------------------------------------
157
158 /**
159 * Public constructor.
160 *
161 * @param parent parent widget
162 * @param x column relative to parent
163 * @param y row relative to parent
164 * @param width number of text cells for width of the image
165 * @param height number of text cells for height of the image
166 * @param image the image to display
167 * @param left left column of the image. 0 is the left-most column.
168 * @param top top row of the image. 0 is the top-most row.
169 */
170 public TImage(final TWidget parent, final int x, final int y,
171 final int width, final int height,
172 final BufferedImage image, final int left, final int top) {
173
174 this(parent, x, y, width, height, image, left, top, null);
175 }
176
177 /**
178 * Public constructor.
179 *
180 * @param parent parent widget
181 * @param x column relative to parent
182 * @param y row relative to parent
183 * @param width number of text cells for width of the image
184 * @param height number of text cells for height of the image
185 * @param image the image to display
186 * @param left left column of the image. 0 is the left-most column.
187 * @param top top row of the image. 0 is the top-most row.
188 * @param clickAction function to call when mouse is pressed
189 */
190 public TImage(final TWidget parent, final int x, final int y,
191 final int width, final int height,
192 final BufferedImage image, final int left, final int top,
193 final TAction clickAction) {
194
195 // Set parent and window
196 super(parent, x, y, width, height);
197
198 setCursorVisible(false);
199 this.originalImage = image;
200 this.left = left;
201 this.top = top;
202 this.clickAction = clickAction;
203
204 sizeToImage(true);
205
206 getApplication().addImage(this);
207 }
208
209 // ------------------------------------------------------------------------
210 // Event handlers ---------------------------------------------------------
211 // ------------------------------------------------------------------------
212
213 /**
214 * Subclasses should override this method to cleanup resources. This is
215 * called by TWindow.onClose().
216 */
217 @Override
218 protected void close() {
219 getApplication().removeImage(this);
220 super.close();
221 }
222
223 /**
224 * Handle mouse press events.
225 *
226 * @param mouse mouse button press event
227 */
228 @Override
229 public void onMouseDown(final TMouseEvent mouse) {
230 if (clickAction != null) {
231 clickAction.DO();
232 return;
233 }
234 }
235
236 /**
237 * Handle keystrokes.
238 *
239 * @param keypress keystroke event
240 */
241 @Override
242 public void onKeypress(final TKeypressEvent keypress) {
243 if (!keypress.getKey().isFnKey()) {
244 if (keypress.getKey().getChar() == '+') {
245 // Make the image bigger.
246 scaleFactor *= 1.25;
247 image = null;
248 sizeToImage(true);
249 return;
250 }
251 if (keypress.getKey().getChar() == '-') {
252 // Make the image smaller.
253 scaleFactor *= 0.80;
254 image = null;
255 sizeToImage(true);
256 return;
257 }
258 }
259 if (keypress.equals(kbAltUp)) {
260 // Make the image bigger.
261 scaleFactor *= 1.25;
262 image = null;
263 sizeToImage(true);
264 return;
265 }
266 if (keypress.equals(kbAltDown)) {
267 // Make the image smaller.
268 scaleFactor *= 0.80;
269 image = null;
270 sizeToImage(true);
271 return;
272 }
273 if (keypress.equals(kbAltRight)) {
274 // Rotate clockwise.
275 clockwise++;
276 clockwise %= 4;
277 image = null;
278 sizeToImage(true);
279 return;
280 }
281 if (keypress.equals(kbAltLeft)) {
282 // Rotate counter-clockwise.
283 clockwise--;
284 if (clockwise < 0) {
285 clockwise = 3;
286 }
287 image = null;
288 sizeToImage(true);
289 return;
290 }
291
292 if (keypress.equals(kbShiftLeft)) {
293 switch (scale) {
294 case NONE:
295 setScaleType(Scale.SCALE);
296 return;
297 case STRETCH:
298 setScaleType(Scale.NONE);
299 return;
300 case SCALE:
301 setScaleType(Scale.STRETCH);
302 return;
303 }
304 }
305 if (keypress.equals(kbShiftRight)) {
306 switch (scale) {
307 case NONE:
308 setScaleType(Scale.STRETCH);
309 return;
310 case STRETCH:
311 setScaleType(Scale.SCALE);
312 return;
313 case SCALE:
314 setScaleType(Scale.NONE);
315 return;
316 }
317 }
318
319 // Pass to parent for the things we don't care about.
320 super.onKeypress(keypress);
321 }
322
323 /**
324 * Handle resize events.
325 *
326 * @param event resize event
327 */
328 @Override
329 public void onResize(final TResizeEvent event) {
330 // Get my width/height set correctly.
331 super.onResize(event);
332
333 if (scale == Scale.NONE) {
334 return;
335 }
336 image = null;
337 resized = true;
338 }
339
340 // ------------------------------------------------------------------------
341 // TWidget ----------------------------------------------------------------
342 // ------------------------------------------------------------------------
343
344 /**
345 * Draw the image.
346 */
347 @Override
348 public void draw() {
349 sizeToImage(false);
350
351 // We have already broken the image up, just draw the last set of
352 // cells.
353 for (int x = 0; (x < getWidth()) && (x + left < cellColumns); x++) {
354 if ((left + x) * lastTextWidth > image.getWidth()) {
355 continue;
356 }
357
358 for (int y = 0; (y < getHeight()) && (y + top < cellRows); y++) {
359 if ((top + y) * lastTextHeight > image.getHeight()) {
360 continue;
361 }
362 assert (x + left < cellColumns);
363 assert (y + top < cellRows);
364
365 getWindow().putCharXY(x, y, cells[x + left][y + top]);
366 }
367 }
368
369 }
370
371 // ------------------------------------------------------------------------
372 // TImage -----------------------------------------------------------------
373 // ------------------------------------------------------------------------
374
375 /**
376 * Size cells[][] according to the screen font size.
377 *
378 * @param always if true, always resize the cells
379 */
380 private void sizeToImage(final boolean always) {
381 int textWidth = 16;
382 int textHeight = 20;
383
384 if (getScreen() instanceof SwingTerminal) {
385 SwingTerminal terminal = (SwingTerminal) getScreen();
386
387 textWidth = terminal.getTextWidth();
388 textHeight = terminal.getTextHeight();
389 } if (getScreen() instanceof MultiScreen) {
390 MultiScreen terminal = (MultiScreen) getScreen();
391
392 textWidth = terminal.getTextWidth();
393 textHeight = terminal.getTextHeight();
394 } else if (getScreen() instanceof ECMA48Terminal) {
395 ECMA48Terminal terminal = (ECMA48Terminal) getScreen();
396
397 textWidth = terminal.getTextWidth();
398 textHeight = terminal.getTextHeight();
399 }
400
401 if (image == null) {
402 image = rotateImage(originalImage, clockwise);
403 image = scaleImage(image, scaleFactor, getWidth(), getHeight(),
404 textWidth, textHeight);
405 }
406
407 if ((always == true) ||
408 (resized == true) ||
409 ((textWidth > 0)
410 && (textWidth != lastTextWidth)
411 && (textHeight > 0)
412 && (textHeight != lastTextHeight))
413 ) {
414 resized = false;
415
416 cellColumns = image.getWidth() / textWidth;
417 if (cellColumns * textWidth < image.getWidth()) {
418 cellColumns++;
419 }
420 cellRows = image.getHeight() / textHeight;
421 if (cellRows * textHeight < image.getHeight()) {
422 cellRows++;
423 }
424
425 // Break the image up into an array of cells.
426 cells = new Cell[cellColumns][cellRows];
427
428 for (int x = 0; x < cellColumns; x++) {
429 for (int y = 0; y < cellRows; y++) {
430
431 int width = textWidth;
432 if ((x + 1) * textWidth > image.getWidth()) {
433 width = image.getWidth() - (x * textWidth);
434 }
435 int height = textHeight;
436 if ((y + 1) * textHeight > image.getHeight()) {
437 height = image.getHeight() - (y * textHeight);
438 }
439
440 Cell cell = new Cell();
441 cell.setImage(image.getSubimage(x * textWidth,
442 y * textHeight, width, height));
443
444 cells[x][y] = cell;
445 }
446 }
447
448 lastTextWidth = textWidth;
449 lastTextHeight = textHeight;
450 }
451
452 if ((left + getWidth()) > cellColumns) {
453 left = cellColumns - getWidth();
454 }
455 if (left < 0) {
456 left = 0;
457 }
458 if ((top + getHeight()) > cellRows) {
459 top = cellRows - getHeight();
460 }
461 if (top < 0) {
462 top = 0;
463 }
464 }
465
466 /**
467 * Get the top corner to render.
468 *
469 * @return the top row
470 */
471 public int getTop() {
472 return top;
473 }
474
475 /**
476 * Set the top corner to render.
477 *
478 * @param top the new top row
479 */
480 public void setTop(final int top) {
481 this.top = top;
482 if (this.top > cellRows - getHeight()) {
483 this.top = cellRows - getHeight();
484 }
485 if (this.top < 0) {
486 this.top = 0;
487 }
488 }
489
490 /**
491 * Get the left corner to render.
492 *
493 * @return the left column
494 */
495 public int getLeft() {
496 return left;
497 }
498
499 /**
500 * Set the left corner to render.
501 *
502 * @param left the new left column
503 */
504 public void setLeft(final int left) {
505 this.left = left;
506 if (this.left > cellColumns - getWidth()) {
507 this.left = cellColumns - getWidth();
508 }
509 if (this.left < 0) {
510 this.left = 0;
511 }
512 }
513
514 /**
515 * Get the number of text cell rows for this image.
516 *
517 * @return the number of rows
518 */
519 public int getRows() {
520 return cellRows;
521 }
522
523 /**
524 * Get the number of text cell columns for this image.
525 *
526 * @return the number of columns
527 */
528 public int getColumns() {
529 return cellColumns;
530 }
531
532 /**
533 * Get the raw (unprocessed) image.
534 *
535 * @return the image
536 */
537 public BufferedImage getImage() {
538 return originalImage;
539 }
540
541 /**
542 * Set the raw image, and reprocess to make the visible image.
543 *
544 * @param image the new image
545 */
546 public void setImage(final BufferedImage image) {
547 this.originalImage = image;
548 this.image = null;
549 sizeToImage(true);
550 }
551
552 /**
553 * Get the visible (processed) image.
554 *
555 * @return the image that is currently on screen
556 */
557 public BufferedImage getVisibleImage() {
558 return image;
559 }
560
561 /**
562 * Get the scaling strategy.
563 *
564 * @return Scale.NONE, Scale.STRETCH, etc.
565 */
566 public Scale getScaleType() {
567 return scale;
568 }
569
570 /**
571 * Set the scaling strategy.
572 *
573 * @param scale Scale.NONE, Scale.STRETCH, etc.
574 */
575 public void setScaleType(final Scale scale) {
576 this.scale = scale;
577 this.image = null;
578 sizeToImage(true);
579 }
580
581 /**
582 * Get the scale factor.
583 *
584 * @return the scale factor
585 */
586 public double getScaleFactor() {
587 return scaleFactor;
588 }
589
590 /**
591 * Set the scale factor. 1.0 means no scaling.
592 *
593 * @param scaleFactor the new scale factor
594 */
595 public void setScaleFactor(final double scaleFactor) {
596 this.scaleFactor = scaleFactor;
597 image = null;
598 sizeToImage(true);
599 }
600
601 /**
602 * Get the rotation, as degrees.
603 *
604 * @return the rotation in degrees
605 */
606 public int getRotation() {
607 switch (clockwise) {
608 case 0:
609 return 0;
610 case 1:
611 return 90;
612 case 2:
613 return 180;
614 case 3:
615 return 270;
616 default:
617 // Don't know how this happened, but fix it.
618 clockwise = 0;
619 image = null;
620 sizeToImage(true);
621 return 0;
622 }
623 }
624
625 /**
626 * Set the rotation, as degrees clockwise.
627 *
628 * @param rotation 0, 90, 180, or 270
629 */
630 public void setRotation(final int rotation) {
631 switch (rotation) {
632 case 0:
633 clockwise = 0;
634 break;
635 case 90:
636 clockwise = 1;
637 break;
638 case 180:
639 clockwise = 2;
640 break;
641 case 270:
642 clockwise = 3;
643 break;
644 default:
645 // Don't know how this happened, but fix it.
646 clockwise = 0;
647 break;
648 }
649
650 image = null;
651 sizeToImage(true);
652 }
653
654 /**
655 * Scale an image by to be scaleFactor size.
656 *
657 * @param image the image to scale
658 * @param factor the scale to make the new image
659 * @param width the number of text cell columns for the destination image
660 * @param height the number of text cell rows for the destination image
661 * @param textWidth the width in pixels for one text cell
662 * @param textHeight the height in pixels for one text cell
663 */
664 private BufferedImage scaleImage(final BufferedImage image,
665 final double factor, final int width, final int height,
666 final int textWidth, final int textHeight) {
667
668 if ((scale == Scale.NONE) && (Math.abs(factor - 1.0) < 0.03)) {
669 // If we are within 3% of 1.0, just return the original image.
670 return image;
671 }
672
673 int destWidth = 0;
674 int destHeight = 0;
675 int x = 0;
676 int y = 0;
677
678 BufferedImage newImage = null;
679
680 switch (scale) {
681 case NONE:
682 destWidth = (int) (image.getWidth() * factor);
683 destHeight = (int) (image.getHeight() * factor);
684 newImage = new BufferedImage(destWidth, destHeight,
685 BufferedImage.TYPE_INT_ARGB);
686 break;
687 case STRETCH:
688 destWidth = width * textWidth;
689 destHeight = height * textHeight;
690 newImage = new BufferedImage(destWidth, destHeight,
691 BufferedImage.TYPE_INT_ARGB);
692 break;
693 case SCALE:
694 double a = (double) image.getWidth() / image.getHeight();
695 double b = (double) (width * textWidth) / (height * textHeight);
696 assert (a > 0);
697 assert (b > 0);
698
699 /*
700 System.err.println("Scale: original " + image.getWidth() +
701 "x" + image.getHeight());
702 System.err.println(" screen " + (width * textWidth) +
703 "x" + (height * textHeight));
704 System.err.println("A " + a + " B " + b);
705 */
706
707 if (a > b) {
708 // Horizontal letterbox
709 destWidth = width * textWidth;
710 destHeight = (int) (destWidth / a);
711 y = ((height * textHeight) - destHeight) / 2;
712 assert (y >= 0);
713 /*
714 System.err.println("Horizontal letterbox: " + destWidth +
715 "x" + destHeight + ", Y offset " + y);
716 */
717 } else {
718 // Vertical letterbox
719 destHeight = height * textHeight;
720 destWidth = (int) (destHeight * a);
721 x = ((width * textWidth) - destWidth) / 2;
722 assert (x >= 0);
723 /*
724 System.err.println("Vertical letterbox: " + destWidth +
725 "x" + destHeight + ", X offset " + x);
726 */
727 }
728 newImage = new BufferedImage(width * textWidth, height * textHeight,
729 BufferedImage.TYPE_INT_ARGB);
730 break;
731 }
732
733 java.awt.Graphics gr = newImage.createGraphics();
734 if (scale == Scale.SCALE) {
735 gr.setColor(scaleBackColor);
736 gr.fillRect(0, 0, width * textWidth, height * textHeight);
737 }
738 gr.drawImage(image, x, y, destWidth, destHeight, null);
739 gr.dispose();
740 return newImage;
741 }
742
743 /**
744 * Rotate an image either clockwise or counterclockwise.
745 *
746 * @param image the image to scale
747 * @param clockwise number of turns clockwise
748 */
749 private BufferedImage rotateImage(final BufferedImage image,
750 final int clockwise) {
751
752 if (clockwise % 4 == 0) {
753 return image;
754 }
755
756 BufferedImage newImage = null;
757
758 if (clockwise % 4 == 1) {
759 // 90 degrees clockwise
760 newImage = new BufferedImage(image.getHeight(), image.getWidth(),
761 BufferedImage.TYPE_INT_ARGB);
762 for (int x = 0; x < image.getWidth(); x++) {
763 for (int y = 0; y < image.getHeight(); y++) {
764 newImage.setRGB(y, x,
765 image.getRGB(x, image.getHeight() - 1 - y));
766 }
767 }
768 } else if (clockwise % 4 == 2) {
769 // 180 degrees clockwise
770 newImage = new BufferedImage(image.getWidth(), image.getHeight(),
771 BufferedImage.TYPE_INT_ARGB);
772 for (int x = 0; x < image.getWidth(); x++) {
773 for (int y = 0; y < image.getHeight(); y++) {
774 newImage.setRGB(x, y,
775 image.getRGB(image.getWidth() - 1 - x,
776 image.getHeight() - 1 - y));
777 }
778 }
779 } else if (clockwise % 4 == 3) {
780 // 270 degrees clockwise
781 newImage = new BufferedImage(image.getHeight(), image.getWidth(),
782 BufferedImage.TYPE_INT_ARGB);
783 for (int x = 0; x < image.getWidth(); x++) {
784 for (int y = 0; y < image.getHeight(); y++) {
785 newImage.setRGB(y, x,
786 image.getRGB(image.getWidth() - 1 - x, y));
787 }
788 }
789 }
790
791 return newImage;
792 }
793
794 }