浏览代码

Create a better version of AutoGrowPanel, to be swapped in next commit.

Sam Jaffe 5 年之前
父节点
当前提交
6c30b5afd2

+ 151 - 0
src/main/lombok/org/leumasjaffe/recipe/view/BetterAutoGrowPanel.java

@@ -0,0 +1,151 @@
+package org.leumasjaffe.recipe.view;
+
+import java.awt.Component;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import javax.swing.JPanel;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+
+import org.jdesktop.swingx.VerticalLayout;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+import lombok.experimental.FieldDefaults;
+import lombok.experimental.NonFinal;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class BetterAutoGrowPanel<C extends Component & BetterAutoGrowPanel.ChildComponent, T> extends JPanel {
+	private static interface SetGap { void setGap(int gap); }
+
+	public static interface ChildComponent {
+		void addGrowShrinkListener(DocumentListener dl);
+		void removeGrowShrinkListener(DocumentListener dl);
+		default void setListPosition(int zeroIndex) {}
+	}
+	
+	@RequiredArgsConstructor
+	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+	private class GrowOnData implements DocumentListener {
+		Supplier<T> makeEmptyModel;
+		Function<T, C> makeComponent;
+		@NonFinal T model = null;
+		
+		@Override public void changedUpdate(DocumentEvent e) {}
+		@Override public void removeUpdate(DocumentEvent e) {}
+
+		@Override
+		public void insertUpdate(DocumentEvent e) {
+			if (model != null) {
+				models.add(model);
+				last().removeGrowShrinkListener(this);
+				last().addGrowShrinkListener(new ShrinkOnEmpty(last()));
+			}
+			
+			model = makeEmptyModel.get();
+			final C comp = makeComponent.apply(model);
+
+			comp.addGrowShrinkListener(this);
+			members.add(comp);
+			add(comp);
+			callback.accept(true);
+		}		
+	}
+	
+	@AllArgsConstructor
+	private class ShrinkOnEmpty implements DocumentListener {
+		ChildComponent component;
+		
+		@Override public void insertUpdate(DocumentEvent e) {}
+		@Override public void changedUpdate(DocumentEvent e) {}
+
+		@Override
+		public void removeUpdate(DocumentEvent e) {
+			if (e.getDocument().getLength() > 0) {
+				return;
+			}
+			
+			component.removeGrowShrinkListener(this);
+			remove(members.indexOf(component));
+			callback.accept(false);
+		}
+	}
+	
+	@Delegate(types={SetGap.class})
+	VerticalLayout layout = new VerticalLayout();
+	List<ChildComponent> members = new ArrayList<>();
+	GrowOnData grow;
+	
+	@NonFinal List<T> models = null;
+	@NonFinal Consumer<Boolean> callback = (b) -> {};
+	
+	/**
+	 * 
+	 * @param makeEmptyModel A function to produce a blank model object for display.
+	 * If the model is updated in such a way as to be non-empty, it will be inserted
+	 * into the list of models.
+	 * @param makeComponent A function to generate a UI object given a model. The
+	 * object must meet the ChildComponent interface to install the AutoGrowPanel's
+	 * growing/shrinking listener objects.
+	 */
+	public BetterAutoGrowPanel(final @NonNull Supplier<T> makeEmptyModel,
+			final @NonNull Function<T, C> makeComponent) {
+		this.grow = new GrowOnData(makeEmptyModel, makeComponent);
+		this.grow.insertUpdate(null);
+	}
+	
+	/**
+	 * Activate this component against the target list of children
+	 * @param models A mutable list object containing the "child models" to be
+	 * rendered in this component
+	 */
+	public void setModel(final @NonNull List<T> models) {
+		setModel(models, (b) -> {});
+	}
+	
+	/**
+	 * 
+	 * @param models A mutable list object containing the "child models" to be
+	 * rendered in this component
+	 * @param callback A callback that will be invoked each time a child is
+	 * added or removed. This allows us to provide some custom interactions
+	 * with the parent's context in case other actions need to occur.
+	 */
+	public void setModel(final @NonNull List<T> models,
+			final Consumer<Boolean> callback) {
+		this.models = models;
+		this.callback = callback;
+		
+		final List<ChildComponent> shim = this.members.subList(0, lastIndex());
+		shim.clear();
+		models.forEach(model -> {
+			final C comp = this.grow.makeComponent.apply(model);
+			comp.addGrowShrinkListener(new ShrinkOnEmpty(comp));
+			shim.add(comp);
+			add(comp);
+		});
+	}
+	
+	@Override
+	public void remove(final int index) {
+		super.remove(index);
+		members.remove(index);
+		for (int size = lastIndex(); size >= index; --size) {
+			members.get(index).setListPosition(index);
+		}
+		models.remove(index);
+	}
+	
+	private int lastIndex() { return members.size() - 1; }
+	
+	private ChildComponent last() {
+		return members.get(lastIndex());
+	}
+}

+ 162 - 0
src/test/java/org/leumasjaffe/recipe/view/BetterAutoGrowPanelTest.java

@@ -0,0 +1,162 @@
+package org.leumasjaffe.recipe.view;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsArrayWithSize.arrayWithSize;
+import static org.mockito.Mockito.*;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+
+import javax.swing.JTextField;
+import javax.swing.event.DocumentListener;
+import javax.swing.text.BadLocationException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.runner.JUnitPlatform;
+import org.junit.runner.RunWith;
+import org.leumasjaffe.recipe.view.BetterAutoGrowPanel.ChildComponent;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@RunWith(JUnitPlatform.class)
+class BetterAutoGrowPanelTest extends SwingTestCase {
+	@SuppressWarnings("serial")
+	private static class MockComponent extends JTextField implements ChildComponent {
+		public MockComponent() {
+		}
+
+		public MockComponent(String s) {
+			super(s);
+		}
+
+		@Override
+		public void addGrowShrinkListener(DocumentListener dl) {
+			super.getDocument().addDocumentListener(dl);
+		}
+
+		@Override
+		public void removeGrowShrinkListener(DocumentListener dl) {
+			super.getDocument().removeDocumentListener(dl);
+		}
+		
+		public String toString() {
+			return this.getClass().getSimpleName() + "@" + this.hashCode();
+		}
+	}
+	
+	@Mock Consumer<Boolean> callback;
+	List<MockComponent> internal = new ArrayList<>();
+	@Spy List<MockComponent> shared = new ArrayList<>();
+	
+	private MockComponent mocked() {
+		final MockComponent mock = spy(new MockComponent());
+		internal.add(mock);
+		return mock;
+	}
+	
+	private BetterAutoGrowPanel<MockComponent, MockComponent> create(MockComponent... mocks) {
+		shared.addAll(Arrays.asList(mocks));
+		final BetterAutoGrowPanel<MockComponent, MockComponent> rval =
+				new BetterAutoGrowPanel<>(this::mocked, m -> m);
+		
+		rval.setModel(shared);
+
+		return rval;
+	}
+	
+	@Test
+	void testAlwaysHasAtLeastOneComponent() {		
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create();
+		
+		assertThat(panel.getComponents(), arrayWithSize(1));
+		verify(shared, never()).add(any());
+	}
+
+	@Test
+	void testCreatesGivenNumberOfChildrenPlusOne() {
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create(mocked(), mocked());
+
+		assertThat(panel.getComponents(), arrayWithSize(3));
+		verify(shared, never()).add(any());
+	}
+
+	@Test
+	void testEnteringContentTriggersNewRow() {
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create();
+		getTestFrame().add(panel);
+
+		internal.get(0).setText("A");
+		assertThat(panel.getComponents(), arrayWithSize(2));
+		
+		internal.get(1).setText("B");
+		assertThat(panel.getComponents(), arrayWithSize(3));
+		verify(shared, times(2)).add(any());
+	}
+
+	@Test
+	void testEnteringEmptyContentDoesNotTrigger() {
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create();
+		getTestFrame().add(panel);
+		internal.get(0).setText("");
+		
+		assertThat(panel.getComponents(), arrayWithSize(1));
+		verify(shared, never()).add(any());
+		verify(shared, never()).remove(anyInt());
+	}
+
+	@Test
+	void testEmptyingContentClearsPanel() {
+		final MockComponent mock = spy(new MockComponent("A"));
+		
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create(mock);
+		getTestFrame().add(panel);
+		mock.setText("");
+		
+		assertThat(panel.getComponents(), arrayWithSize(1));
+		verify(shared, times(1)).remove(0);
+	}
+
+	@Test
+	void testRemovingSomeContentDoesntClear() throws BadLocationException {
+		final MockComponent mock = spy(new MockComponent("AB"));
+		
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create(mock);
+		getTestFrame().add(panel);
+		mock.getDocument().remove(0, 1);
+		
+		assertThat(panel.getComponents(), arrayWithSize(2));
+		verify(shared, never()).remove(anyInt());
+	}
+
+	@Test
+	void testChangingTextDoesNotDeleteRow() throws BadLocationException {
+		final MockComponent mock = spy(new MockComponent("A"));
+		
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create(mock);
+		getTestFrame().add(panel);
+		mock.getDocument().insertString(0, "B", null);
+		
+		assertThat(panel.getComponents(), arrayWithSize(2));
+		verify(shared, never()).remove(anyInt());
+	}
+
+	@Test
+	void testCanInstallNotificationCallbackForAddsAndDeletes() {
+		BetterAutoGrowPanel<MockComponent, MockComponent> panel = create();
+		panel.setModel(shared, callback);
+		getTestFrame().add(panel);
+		verify(callback, never()).accept(anyBoolean());
+
+		internal.get(0).setText("A");
+		verify(callback).accept(true);
+
+		internal.get(0).setText("");
+		verify(callback).accept(false);
+	}
+	
+}