View Javadoc

1   /*
2    *  XNap Commons
3    *
4    *  Copyright (C) 2005  Felix Berger
5    *  Copyright (C) 2005  Steffen Pingel
6    *
7    *  This library is free software; you can redistribute it and/or
8    *  modify it under the terms of the GNU Lesser General Public
9    *  License as published by the Free Software Foundation; either
10   *  version 2.1 of the License, or (at your option) any later version.
11   *
12   *  This library is distributed in the hope that it will be useful,
13   *  but WITHOUT ANY WARRANTY; without even the implied warranty of
14   *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15   *  Lesser General Public License for more details.
16   *
17   *  You should have received a copy of the GNU Lesser General Public
18   *  License along with this library; if not, write to the Free Software
19   *  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
20   */
21  package org.xnap.commons.gui;
22  
23  import java.awt.Component;
24  import java.awt.Graphics;
25  import java.awt.Insets;
26  import java.awt.Point;
27  import java.awt.Rectangle;
28  import java.awt.event.ActionEvent;
29  import java.awt.event.ComponentAdapter;
30  import java.awt.event.ComponentEvent;
31  import java.awt.event.MouseAdapter;
32  import java.awt.event.MouseEvent;
33  import java.awt.event.MouseMotionListener;
34  import java.lang.reflect.Method;
35  
36  import javax.swing.AbstractButton;
37  import javax.swing.Action;
38  import javax.swing.Box;
39  import javax.swing.BoxLayout;
40  import javax.swing.Icon;
41  import javax.swing.JLabel;
42  import javax.swing.JLayeredPane;
43  import javax.swing.JRootPane;
44  import javax.swing.JTabbedPane;
45  import javax.swing.SwingConstants;
46  import javax.swing.SwingUtilities;
47  import javax.swing.UIManager;
48  import javax.swing.event.ChangeEvent;
49  import javax.swing.event.ChangeListener;
50  
51  import org.xnap.commons.gui.action.AbstractXNapAction;
52  import org.xnap.commons.gui.util.IconHelper;
53  
54  /***
55   * Provides a <code>JTabbedPane</code> with close buttons  in the tab titles.
56   * The buttons of the currently active tab will be visible and the button
57   * of the tab the mouse if hovered over.
58   * 
59   * <p> 
60   * If a button is clicked the tab is removed from the pane unless a CloseListener
61   * has been set.
62   * 
63   * <p>Note: If a button is displayed on a non active tab and the mouse is
64   * moved very fast out of the tabbed pane, the button may still be visible.
65   * 
66   * @author Felix Berger
67   * @author Steffen Pingel
68   */
69  @SuppressWarnings("serial")
70  public class CloseableTabbedPane extends JTabbedPane {
71      
72  	private final static int ICON_TEXT_GAP = 8;
73      private MouseHandler mouseHandler = new MouseHandler();
74      private SelectionHandler selHandler = new SelectionHandler();
75      private ComponentHandler compHandler = new ComponentHandler();
76      private CloseListener closeListener = null;
77  	private boolean useScrollHack;
78  	private Method getTabComponentAtMethod;
79  	private Method setTabComponentAtMethod;
80      private boolean java6 = false;
81      
82  	/***
83  	 * Creates a tabbed pane with close buttons and a
84  	 * <code>WRAP_TAB_LAYOUT</code>
85  	 */
86      public CloseableTabbedPane()
87      {
88      	try {
89  			getTabComponentAtMethod = getClass().getMethod("getTabComponentAt", int.class);
90  	    	setTabComponentAtMethod = getClass().getMethod("setTabComponentAt", int.class, Component.class);
91  	    	java6 = true;
92      	} catch (Throwable t) {
93  		}
94      	
95      	setTabLayoutPolicy(JTabbedPane.WRAP_TAB_LAYOUT);
96      	addMouseMotionListener(mouseHandler);
97      	addMouseListener(mouseHandler);
98  		getModel().addChangeListener(selHandler);
99  		addComponentListener(compHandler);
100     }
101     
102     /***
103      * Sets the listener that is notified of user initiated close requests
104      * for single tabs.
105      * @param listener can be <code>null</code>, then the component
106      * is simply removed
107      */
108     public void setCloseListener(CloseListener listener)
109     {
110     	closeListener = listener;
111     }
112     
113     /***
114      * Adds a <code>compnent</code> with a tab <code>title</code> and
115      * an <code>icon</code> to the tabbed pane.
116      * <p>
117      * The icon is displayed on the left side of the title and does not affect
118      * the icon of the closing button for this tab. 
119      * @param title the title of the tab, can be <code>null</code>
120      * @param component 
121      * @param icon can be <code>null</code>
122      * @param closeable whether or not a close button should be displayed
123      * for this tabbed pane.
124      */
125 	public void addTab(String title, Component component, Icon icon, 
126 					   boolean closeable) 
127     {
128 		if (closeable) {
129 			if (java6) {
130 				super.addTab(null, component);
131 				TabTitleComponent tabTitle = new TabTitleComponent(title, component, icon);
132 				try {
133 					setTabComponentAtMethod.invoke(this, this.getTabCount() - 1, tabTitle);
134 				} catch (Throwable t) {
135 					throw new RuntimeException(t);
136 				}
137 			} 
138 			else {
139 				super.addTab(null, new TabTitleIcon(title, component, icon), component);
140 			}
141 		}
142 		else {
143 			super.addTab(title, icon, component);
144 		}
145 		setSelectedComponent(component);
146     }
147 
148 	/***
149 	 * Convenience wrapper for {@link #addTab(String, Component, Icon, boolean)
150 	 * addTab(String, Component, Icon, true)}.
151 	 */
152 	public void addTab(String title, Component component, Icon icon)
153 	{
154 		addTab(title, component, icon, true);
155 	}
156 
157 	/***
158 	 * Convenience wrapper for {@link #addTab(String, Component, Icon, boolean)
159 	 * addTab(String, Component, null, true)}.
160 	 */
161 	@Override
162     public void addTab(String title, Component component) 
163     {
164 		addTab(title, component, null, true);
165     }
166 
167 	@Override
168 	public void setTitleAt(int index, String newTitle)
169 	{
170 		TabTitle titleComponent = getTabTitleComponentAt(index);
171 		if (titleComponent != null) {
172 			titleComponent.setTitle(newTitle);
173 		}
174 		else {
175 			super.setTitleAt(index, newTitle);
176 		}
177 	}
178 	
179 	private TabTitle getTabTitleComponentAt(int index) {
180 		if (java6) {
181 			try {
182 				Object tabTitle = getTabComponentAtMethod.invoke(this, index);
183 				if (tabTitle instanceof TabTitle) {
184 					return (TabTitle) tabTitle;
185 				}
186 			} catch (Throwable t) {
187 				throw new RuntimeException(t);
188 			}
189 		} else {	
190 			Icon icon = getIconAt(index);
191 			if (icon instanceof TabTitle) {
192 				return (TabTitle)icon;
193 			}
194 		}
195 		return null;
196 	}
197 	
198 	/***
199 	 * This method does not work properly for tabs that have a close
200 	 * button.
201 	 * <p>
202 	 * Keep that information somewhere else if you need it.
203 	 * @return the empty string for tabs that have a close button,
204 	 * the title of the tab otherwise
205 	 */
206 	@Override
207 	public String getTitleAt(int index)
208 	{
209 		return super.getTitleAt(index);
210 	}
211     
212 	/***
213 	 * Sets the tab layout policy. Currently <code>WRAP_TAB_LAYOUT</code> is
214 	 * strongly recommended.
215 	 * 
216 	 * <p>
217 	 * Explanation: <code>SCROLL_TAB_LAYOUT</code> uses a private view
218 	 * translation for the scrolling of the tabs in
219 	 * <code>BasicTabbedPaneUI</code>. The <code>EventIcon.paint()</code>
220 	 * method receives coordinates from the ui view port and since it is not
221 	 * possible to translate coordinates between the tabbed pane and the view
222 	 * port the closing button can not be placed at the correct location.
223 	 * 
224 	 * <p>
225 	 * However there is a crude work around:
226 	 * <code>TabbedPaneUI.getTabBounds()</code> returns tabbed pane
227 	 * coordinates whereas <code>TabTitleIcon.paint()</code> receives view
228 	 * port coordinates. When <code>TabTitleIcon.paint()</code> is invoked it
229 	 * can finds out its tab bounds in tabbed pane coordinates and use those
230 	 * to set the button's bounds. The location can only be somewhat estimated
231 	 * as it depends on the tab style of the look&feel. 
232 	 * 
233 	 * @param tabLayoutPolicy
234 	 *            must be <code>JTabbedPane.WRAP_TAB_LAYOUT</code>
235 	 * @see javax.swing.JTabbedPane#setTabLayoutPolicy(int)
236 	 */
237 	@Override
238 	public void setTabLayoutPolicy(int tabLayoutPolicy)
239 	{
240 		useScrollHack = (tabLayoutPolicy == SCROLL_TAB_LAYOUT);
241 		super.setTabLayoutPolicy(tabLayoutPolicy);
242 	}
243 
244 	@Override
245 	public void updateUI()
246 	{
247 		super.updateUI();
248 		int tabCount = getTabCount();
249 		for (int i = 0; i < tabCount; i++) {
250 			Icon icon = getIconAt(i);
251 			if (icon instanceof TabTitle) {
252 				((TabTitle)icon).updateUI();
253 			}
254 		}
255 	}
256 
257     private void setButtonVisible(int index, boolean visible) 
258     {
259 		TabTitle tabTitle = getTabTitleComponentAt(index);
260 		if (tabTitle != null) {
261 			tabTitle.setButtonVisible(visible);
262 		}
263     }
264     
265 	@Override
266 	public void removeTabAt(int index) {
267 		TabTitle tabTitle = getTabTitleComponentAt(index);
268 		super.removeTabAt(index);
269 		if (tabTitle != null) {
270 			tabTitle.disable();
271 		}
272 		if (mouseHandler.visibleIndex == index) {
273 			mouseHandler.visibleIndex = -1;
274 		}
275 		if (selHandler.oldIndex == index) {
276 			selHandler.oldIndex = -1;
277 		}
278 		int sel = getSelectedIndex();
279 		if (sel != -1) {
280 			setButtonVisible(sel, true);
281 		}
282 	}
283 
284     public static interface CloseListener
285     {
286     	/***
287     	 * Called when the user clicked the close button of the tab containing
288     	 * <code>component</code>.
289     	 * @param component that should be removed
290     	 */
291     	void closeRequested(Component component);
292     }
293     
294     public interface TabTitle {
295 
296     	public abstract void updateUI();
297 
298     	public abstract void setTitle(String newTitle);
299 
300     	public abstract String getTitle();
301 
302     	public abstract void setButtonVisible(boolean visible);
303 
304     	public abstract void disable();
305 
306     }
307     
308 	private class DefaultCloseAction extends AbstractXNapAction 
309 	{
310 		
311 		Component comp;
312 		
313 		public DefaultCloseAction(Component comp)
314 		{
315 			this.comp = comp;
316 			putValue(AbstractXNapAction.ICON_FILENAME, "remove.png");
317 		}
318 		
319 		public void actionPerformed(ActionEvent e) {
320 			if (closeListener != null) {
321 				closeListener.closeRequested(comp);
322 			}
323 			else {
324 				remove(comp);
325 			}
326 		}
327 		
328 	}
329 	
330 	protected class TabTitleComponent extends Box implements TabTitle {
331 
332 		private JLabel titleLabel;
333 		private TabTitleButton closeButton;
334 		private Component placeholder;
335 
336 		public TabTitleComponent(String title, Component component,
337 				Icon icon) {
338 			super(BoxLayout.X_AXIS);
339 			
340 			titleLabel = new JLabel(title);
341 			titleLabel.setIcon(icon);
342 			titleLabel.setIconTextGap(ICON_TEXT_GAP);
343 			titleLabel.setHorizontalTextPosition(SwingConstants.RIGHT);
344 			add(titleLabel);
345 			
346 			add(Box.createHorizontalStrut(ICON_TEXT_GAP));
347 			
348 			closeButton = new TabTitleButton(new DefaultCloseAction(component));
349 			add(closeButton);
350 			
351 			// placeholder is displayed when the close button is not visible 
352 			// to avoid resizing of the component   
353 			placeholder = Box.createRigidArea(closeButton.getPreferredSize());
354 		}
355 
356 		public void disable() {
357 		}
358 
359 		public String getTitle() {
360 			return titleLabel.getText();
361 		}
362 
363 		public void setTitle(String newTitle) {
364 			titleLabel.setText(newTitle);
365 		}
366 
367 		public void setButtonVisible(boolean visible) {
368 			closeButton.setVisible(visible);
369 			if (!visible) {
370 				add(placeholder);
371 			} else {
372 				remove(placeholder);
373 			}
374 		}
375 
376 	}
377 	
378     /***
379      * Provides an Icon that can displays a text that can have an icon to 
380      * its left and an <code>EventIcon</code> to its right.
381      */
382     protected class TabTitleIcon implements Icon, TabTitle {
383 
384 		private Icon leftIcon;
385 		private EventIcon closeButtonIcon;
386 		private String title;
387 		private int height = 10;
388 
389 		public TabTitleIcon(String title, Component comp, Icon leftIcon)
390 		{
391 			this.title = title;
392 			this.leftIcon = leftIcon;
393 
394 			this.closeButtonIcon = new EventIcon(new TabTitleButton(new DefaultCloseAction(comp)));
395 
396 			height = Math.max(this.closeButtonIcon.getIconHeight(), leftIcon != null ? 
397 					leftIcon.getIconHeight() : 0);
398 		}
399 	
400 		/* (non-Javadoc)
401 		 * @see org.xnap.commons.gui.TabComponent#updateUI()
402 		 */
403 		public void updateUI() {
404 			closeButtonIcon.updateUI();
405 		}
406 
407 		public TabTitleIcon(String title, Component comp)
408 		{
409 			this(title, comp, null);
410 		}
411 
412 		/* (non-Javadoc)
413 		 * @see org.xnap.commons.gui.TabComponent#setTitle(java.lang.String)
414 		 */
415 		public void setTitle(String newTitle)
416 		{
417 			title = newTitle;
418 		}
419 
420 		/* (non-Javadoc)
421 		 * @see org.xnap.commons.gui.TabComponent#getTitle()
422 		 */
423 		public String getTitle()
424 		{
425 			return title;
426 		}
427 		
428 		/* (non-Javadoc)
429 		 * @see org.xnap.commons.gui.TabComponent#getIconHeight()
430 		 */
431 		public int getIconHeight()
432 		{
433 			return height;
434 		}
435 
436 		public int getIconWidth()
437 		{
438 			int textWidth= SwingUtilities.computeStringWidth
439 				(CloseableTabbedPane.this.getFontMetrics(CloseableTabbedPane.this.getFont()), 
440 				 title);
441 			if (leftIcon != null) {
442 				return leftIcon.getIconWidth() + closeButtonIcon.getIconWidth()
443 					+ textWidth + 2 * ICON_TEXT_GAP;
444 			}
445 			else {
446 				return textWidth + ICON_TEXT_GAP + closeButtonIcon.getIconWidth();
447 			}
448 		}
449 
450 		/***
451 		 * Overwrites paintIcon to get hold of the coordinates of the icon.
452 		 */
453 		public void paintIcon(Component c, Graphics g, int x, int y)
454 		{			
455 			if (leftIcon != null) {
456 				leftIcon.paintIcon(c, g, x, y + 1);
457 				drawTitleAndRightIcon(c, g, x, y, leftIcon.getIconWidth() + ICON_TEXT_GAP);
458 			}
459 			else {
460 				drawTitleAndRightIcon(c, g, x, y, 0);
461 			}
462 		}
463 
464 		protected Rectangle computeTextRect(Graphics g, int x, int y) {
465 			// compute the correct y coordinate where to put the text
466 			Rectangle rect = new Rectangle(x, y, getIconWidth(), 
467 										   getIconHeight());
468 			Rectangle iconRect = new Rectangle();
469 			Rectangle textRect = new Rectangle();
470 			SwingUtilities.layoutCompoundLabel
471 				(CloseableTabbedPane.this, g.getFontMetrics(),
472 				 title, null, SwingUtilities.CENTER,
473 				 SwingUtilities.CENTER,
474 				 SwingUtilities.CENTER,
475 				 SwingUtilities.TRAILING,
476 				 rect, iconRect, textRect,
477 				 UIManager.getInt("TabbedPane.textIconGap"));
478 			return textRect;
479 		}
480 
481 		protected void drawTitleAndRightIcon(Component c, Graphics g, int x, int y, int offset)
482 		{
483 			Rectangle textRect = computeTextRect(g, x, y);
484 			
485 			int index = CloseableTabbedPane.this.indexOfTab(this);
486 			g.setColor((index != -1) 
487 					? CloseableTabbedPane.this.getForegroundAt(index)
488 					: CloseableTabbedPane.this.getForegroundAt(index));
489 			g.setFont(CloseableTabbedPane.this.getFont());
490 			g.drawString(title, x + offset,
491 					 textRect.y + g.getFontMetrics().getAscent());
492 			
493 			if (useScrollHack) {
494 				if (index != -1) {
495 					Rectangle bounds = getUI().getTabBounds(CloseableTabbedPane.this, index);
496 					Insets tabInsets = UIManager.getInsets("TabbedPane.tabInsets");
497 					closeButtonIcon.paintIcon
498 						(c, g, bounds.x + tabInsets.left + getIconWidth()  + 3 - closeButtonIcon.getIconWidth(), bounds.y + tabInsets.top + 1);
499 				}
500 				else {
501 					// will this ever happen?
502 					closeButtonIcon.paintIcon
503 						(c, g, x + getIconWidth() - closeButtonIcon.getIconWidth(), y + 1);
504 				}
505 			}
506 			else {
507 				closeButtonIcon.paintIcon
508 					(c, g, x + getIconWidth() - closeButtonIcon.getIconWidth(), y + 1);
509 			}
510 		}
511 		
512 		public void setButtonVisible(boolean b) {
513 			closeButtonIcon.setVisible(b);
514 		}
515 
516 		public void disable() {
517 			closeButtonIcon.disable();
518 		}
519 	
520 	}
521 
522     /***
523      * Acts as a proxy class for the closing icon. 
524      */
525     private class EventIcon implements Icon {
526 
527 		AbstractButton button;
528 		JLayeredPane pane;
529 
530 		public EventIcon(AbstractButton button)
531 		{
532 			this.button = button;
533 			JRootPane rootPane = SwingUtilities.getRootPane(CloseableTabbedPane.this);
534 			if (rootPane != null) {
535 				pane = rootPane.getLayeredPane();
536 				pane.add(button, JLayeredPane.PALETTE_LAYER);
537 			}
538 		}
539 
540 		public void updateUI() {
541 			button.updateUI();
542 		}
543 
544 		public void disable() {
545 			button.setVisible(false);
546 			button.setEnabled(false);
547 			pane.remove(button);
548 		}
549 
550 		public void setVisible(boolean b) {
551 			button.setVisible(b);
552 		}
553 
554 		public int getIconHeight()
555 		{
556 			return button.getPreferredSize().height;
557 		}
558 
559 		public int getIconWidth()
560 		{
561 			return button.getPreferredSize().width;
562 		}
563 		
564 		/***
565 		 * Repositions the button.
566 		 */
567 		public void paintIcon(Component c, Graphics g, int x, int y)
568 		{
569 			if (pane == null) {
570 				JRootPane rootPane = SwingUtilities.getRootPane(CloseableTabbedPane.this);
571 				pane = rootPane.getLayeredPane();
572 				pane.add(button, JLayeredPane.PALETTE_LAYER);
573 			}
574 			
575 			Point p = SwingUtilities.convertPoint(c, x, y, pane);
576 			button.setBounds(p.x, p.y, getIconHeight(), getIconWidth());
577 		}    
578     }
579     
580     private class TabTitleButton extends ToolBarButton
581     {
582 
583 		public TabTitleButton(Action action)
584     	{
585     		super(action);
586     		String iconName = (String)action.getValue(AbstractXNapAction.ICON_FILENAME);
587     		setIcon(IconHelper.getTabTitleIcon(iconName));
588     		setMargin(new Insets(0, 0, 0, 0));
589     	}
590     }
591     
592     private class MouseHandler extends MouseAdapter implements MouseMotionListener
593     {
594 
595     	int visibleIndex = -1;
596     	
597     	@Override
598     	public void mouseEntered(MouseEvent event)
599     	{
600     		updateButton(event);
601     	}
602 
603     	@Override
604     	public void mouseExited(MouseEvent event)
605     	{
606     		// FIXME can not hide the button because we may have entered it
607     		//updateButton(event);
608     	}
609 
610 		public void mouseMoved(MouseEvent event) {
611 			updateButton(event);
612 		}
613 
614 		public void mouseDragged(MouseEvent event)
615 		{
616 			updateButton(event);
617 		}
618 
619 		private void updateButton(MouseEvent event)
620 		{
621 			int index = getUI().tabForCoordinate(CloseableTabbedPane.this, event.getX(), event.getY());
622 			if (index != -1 && index != getSelectedIndex()) {
623 				if (visibleIndex == index) {
624 					return;
625 				}
626 				setButtonVisible(index, true);
627 				if (visibleIndex != index && visibleIndex != -1 && visibleIndex != getSelectedIndex()) {
628 					setButtonVisible(visibleIndex, false);
629 				}
630 				visibleIndex = index;
631 			} else if (visibleIndex != -1 && visibleIndex != getSelectedIndex() && visibleIndex < getTabCount()) {
632 				setButtonVisible(visibleIndex, false);
633 				visibleIndex = -1;
634 			}
635 		}
636     }
637 
638     private class SelectionHandler implements ChangeListener
639     {
640     	int oldIndex = -1;
641     	
642         /*** 
643          * Invoked when a tab is selected.
644          */
645 		public void stateChanged(ChangeEvent e) {
646 			if (oldIndex != -1 && oldIndex < CloseableTabbedPane.this.getTabCount()) {
647 				CloseableTabbedPane.this.setButtonVisible(oldIndex, false);
648 			}
649 			oldIndex = getSelectedIndex();
650 			if (oldIndex != -1) {
651 				CloseableTabbedPane.this.setButtonVisible(oldIndex, true);
652 			}
653 		}
654     	
655     }
656 
657 	private class ComponentHandler extends ComponentAdapter
658 	{
659 
660 		@Override
661 		public void componentHidden(ComponentEvent e) {
662 			int sel = getSelectedIndex();
663 			if (sel != -1) {
664 				setButtonVisible(sel, false);
665 			}
666 		}
667 
668 		@Override
669 		public void componentShown(ComponentEvent e) {
670 			int sel = getSelectedIndex();
671 			if (sel != -1) {
672 				setButtonVisible(sel, true);
673 			}
674 		}
675 		
676 	}
677 }