diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ButtonElement.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ButtonElement.java index a240fe13..d9779d98 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ButtonElement.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ButtonElement.java @@ -1,5 +1,6 @@ package com.spinyowl.spinygui.core.node; +import static com.spinyowl.spinygui.core.node.NodeBuilder.ATTR_DISABLED; import static com.spinyowl.spinygui.core.node.NodeBuilder.ATTR_TYPE; import static com.spinyowl.spinygui.core.node.NodeBuilder.NODE_BUTTON; import static com.spinyowl.spinygui.core.node.NodeBuilder.TYPE_BUTTON; @@ -37,6 +38,11 @@ public void type(String type) { this.type = type == null || type.isBlank() ? TYPE_SUBMIT : type; } + @Override + public boolean disabled() { + return hasAttribute(ATTR_DISABLED); + } + public boolean plainButton() { return TYPE_BUTTON.equalsIgnoreCase(type); } @@ -50,6 +56,6 @@ public boolean resetButton() { } public boolean activatable() { - return plainButton() || submitButton() || resetButton(); + return !disabled() && (plainButton() || submitButton() || resetButton()); } } diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Element.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Element.java index b4162f11..38774c2c 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Element.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Element.java @@ -114,6 +114,11 @@ public boolean hasAttribute(String attribute) { return attributes.containsKey(attribute); } + /** Returns whether this element is a control disabled by its {@code disabled} attribute. */ + public boolean disabled() { + return false; + } + @Override public void removeAttribute(String attribute) { attributes.remove(attribute); diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/InputElement.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/InputElement.java index 6cd87b24..0e268946 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/InputElement.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/InputElement.java @@ -1,5 +1,6 @@ package com.spinyowl.spinygui.core.node; +import static com.spinyowl.spinygui.core.node.NodeBuilder.ATTR_DISABLED; import static com.spinyowl.spinygui.core.node.NodeBuilder.ATTR_TYPE; import static com.spinyowl.spinygui.core.node.NodeBuilder.ATTR_VALUE; import static com.spinyowl.spinygui.core.node.NodeBuilder.NODE_INPUT; @@ -115,6 +116,11 @@ public void textScrollLeft(float textScrollLeft) { this.textScrollLeft = Math.max(0, textScrollLeft); } + @Override + public boolean disabled() { + return hasAttribute(ATTR_DISABLED); + } + public boolean textInput() { return TYPE_TEXT.equalsIgnoreCase(type); } diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/NodeBuilder.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/NodeBuilder.java index b22a1e2b..4ef8a399 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/NodeBuilder.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/NodeBuilder.java @@ -11,6 +11,7 @@ public final class NodeBuilder { public static final String ATTR_TYPE = "type"; public static final String ATTR_NAME = "name"; public static final String ATTR_VALUE = "value"; + public static final String ATTR_DISABLED = "disabled"; public static final String ATTR_ROWS = "rows"; public static final String ATTR_COLS = "cols"; @@ -99,6 +100,11 @@ public static Attribute value(String value) { return attr(ATTR_VALUE, value); } + /** Creates a boolean {@code disabled} attribute. */ + public static Attribute disabled() { + return attr(ATTR_DISABLED, ""); + } + public static Attribute rows(String value) { return attr(ATTR_ROWS, value); } diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/TextareaElement.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/TextareaElement.java index 2ea3b130..5830770d 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/TextareaElement.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/TextareaElement.java @@ -1,5 +1,6 @@ package com.spinyowl.spinygui.core.node; +import static com.spinyowl.spinygui.core.node.NodeBuilder.ATTR_DISABLED; import static com.spinyowl.spinygui.core.node.NodeBuilder.NODE_TEXTAREA; import java.util.Map; @@ -105,6 +106,11 @@ public void textScrollLeft(float textScrollLeft) { this.textScrollLeft = Math.max(0, textScrollLeft); } + @Override + public boolean disabled() { + return hasAttribute(ATTR_DISABLED); + } + private int clampTextIndex(int index) { return TextIndexNormalizer.clampAndSnapBackward(value, index); } diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/css/visitor/SelectorVisitor.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/css/visitor/SelectorVisitor.java index 62785bf5..ee071219 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/css/visitor/SelectorVisitor.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/css/visitor/SelectorVisitor.java @@ -14,6 +14,7 @@ import com.spinyowl.spinygui.core.style.stylesheet.selector.combinator.DescendantSelector; import com.spinyowl.spinygui.core.style.stylesheet.selector.combinator.GeneralSiblingSelector; import com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoclass.ActiveSelector; +import com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoclass.DisabledSelector; import com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoclass.FocusSelector; import com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoclass.HoverSelector; import com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoelement.AfterSelector; @@ -99,6 +100,10 @@ public Selector visitPseudo(PseudoContext ctx) { return new ActiveSelector(); } + if ("disabled".equals(selectorName)) { + return new DisabledSelector(); + } + if ("before".equals(selectorName)) { return new BeforeSelector(); } diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoclass/DisabledSelector.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoclass/DisabledSelector.java new file mode 100644 index 00000000..68d4e2da --- /dev/null +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoclass/DisabledSelector.java @@ -0,0 +1,18 @@ +package com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoclass; + +import com.spinyowl.spinygui.core.node.Element; +import com.spinyowl.spinygui.core.style.stylesheet.selector.PseudoClassSelector; + +/** Matches supported form controls that currently have the boolean {@code disabled} attribute. */ +public class DisabledSelector implements PseudoClassSelector { + + @Override + public boolean test(Element element) { + return element.disabled(); + } + + @Override + public String toString() { + return ":disabled"; + } +} diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListener.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListener.java index 71006ab3..1f6e8ae0 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListener.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListener.java @@ -62,7 +62,7 @@ public void processWithImpact( private void processInternal( SystemCharEvent event, Frame frame, InputProcessingBatch batch) { var focusedElement = frame.getFocusedElement(); - if (focusedElement == null) { + if (focusedElement == null || focusedElement.disabled()) { return; } diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListener.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListener.java index a416993a..bf8e5582 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListener.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListener.java @@ -80,6 +80,10 @@ public void processWithImpact( private void processInternal( SystemKeyEvent event, Frame frame, InputProcessingBatch batch) { var element = frame.getFocusedElement(); + if (element != null && element.disabled()) { + element.pressed(false); + return; + } if (element == null && batch == null) { return; } diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemMouseClickEventListener.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemMouseClickEventListener.java index 1b12eded..653558a5 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemMouseClickEventListener.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemMouseClickEventListener.java @@ -90,6 +90,16 @@ public void process(@NonNull SystemMouseClickEvent event, @NonNull Frame frame) processWithNoTarget(event, frame, focusedElement, currentCursorPosition); } else { target = buttonOwner(target); + if (target.disabled()) { + target.pressed(false); + if (event.action() == RELEASE && focusedElement != null && focusedElement != target) { + focusedElement.pressed(false); + if (!focusedElement.disabled()) { + generateReleaseEvent(event, frame, focusedElement, currentCursorPosition); + } + } + return; + } processWithExistingTarget(event, frame, focusedElement, currentCursorPosition, target); } } @@ -291,7 +301,7 @@ private void generateActionEvent(Frame frame, Element target) { private boolean activatable(Element target) { return target instanceof ButtonElement button && button.activatable() - || target instanceof InputElement input && input.buttonInput(); + || target instanceof InputElement input && input.buttonInput() && !input.disabled(); } private Element buttonOwner(Element target) { diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/input/ButtonBehavior.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/input/ButtonBehavior.java index 9b035f2a..7483d017 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/input/ButtonBehavior.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/system/input/ButtonBehavior.java @@ -14,12 +14,16 @@ public boolean handleKey(ButtonElement button, KeyCode keyCode, KeyAction action } public boolean handleKey(InputElement input, KeyCode keyCode, KeyAction action) { - return handleKey(input, input.buttonInput(), keyCode, action); + return handleKey(input, input.buttonInput() && !input.disabled(), keyCode, action); } private boolean handleKey( Element element, boolean activatable, KeyCode keyCode, KeyAction action) { - if (!activatable || !activationKey(keyCode)) { + if (!activatable) { + element.pressed(false); + return false; + } + if (!activationKey(keyCode)) { return false; } diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/node/DisabledAttributeTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/node/DisabledAttributeTest.java new file mode 100644 index 00000000..af9308b6 --- /dev/null +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/node/DisabledAttributeTest.java @@ -0,0 +1,57 @@ +package com.spinyowl.spinygui.core.node; + +import static com.spinyowl.spinygui.core.node.NodeBuilder.ATTR_DISABLED; +import static com.spinyowl.spinygui.core.node.NodeBuilder.TYPE_BUTTON; +import static com.spinyowl.spinygui.core.node.NodeBuilder.attrs; +import static com.spinyowl.spinygui.core.node.NodeBuilder.button; +import static com.spinyowl.spinygui.core.node.NodeBuilder.disabled; +import static com.spinyowl.spinygui.core.node.NodeBuilder.div; +import static com.spinyowl.spinygui.core.node.NodeBuilder.input; +import static com.spinyowl.spinygui.core.node.NodeBuilder.textarea; +import static com.spinyowl.spinygui.core.node.NodeBuilder.type; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class DisabledAttributeTest { + + @Test + void disabledAttributeMarksSupportedControlsDisabled() { + ButtonElement button = button(attrs(disabled())); + InputElement input = input(attrs(disabled())); + TextareaElement textarea = textarea(attrs(disabled()), "value"); + + assertTrue(button.disabled()); + assertTrue(input.disabled()); + assertTrue(textarea.disabled()); + } + + @Test + void disabledAttributeUsesBooleanPresenceSemantics() { + InputElement input = input(); + + input.setAttribute(ATTR_DISABLED, "false"); + assertTrue(input.disabled()); + + input.removeAttribute(ATTR_DISABLED); + assertFalse(input.disabled()); + } + + @Test + void unsupportedElementDoesNotBecomeDisabled() { + Element element = div(attrs(disabled())); + + assertFalse(element.disabled()); + } + + @Test + void disabledButtonIsNotActivatable() { + ButtonElement button = button(attrs(disabled(), type(TYPE_BUTTON))); + + assertFalse(button.activatable()); + + button.removeAttribute(ATTR_DISABLED); + assertTrue(button.activatable()); + } +} diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoclass/DisabledSelectorTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoclass/DisabledSelectorTest.java new file mode 100644 index 00000000..3b975d47 --- /dev/null +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoclass/DisabledSelectorTest.java @@ -0,0 +1,32 @@ +package com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoclass; + +import static com.spinyowl.spinygui.core.node.NodeBuilder.attrs; +import static com.spinyowl.spinygui.core.node.NodeBuilder.button; +import static com.spinyowl.spinygui.core.node.NodeBuilder.disabled; +import static com.spinyowl.spinygui.core.node.NodeBuilder.div; +import static com.spinyowl.spinygui.core.node.NodeBuilder.input; +import static com.spinyowl.spinygui.core.node.NodeBuilder.textarea; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class DisabledSelectorTest { + + private final DisabledSelector selector = new DisabledSelector(); + + @Test + void matchesDisabledSupportedControls() { + assertTrue(selector.test(button(attrs(disabled())))); + assertTrue(selector.test(input(attrs(disabled())))); + assertTrue(selector.test(textarea(attrs(disabled()), "value"))); + } + + @Test + void doesNotMatchEnabledOrUnsupportedElements() { + assertFalse(selector.test(button())); + assertFalse(selector.test(input())); + assertFalse(selector.test(textarea())); + assertFalse(selector.test(div(attrs(disabled())))); + } +} diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/DisabledControlEventListenerTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/DisabledControlEventListenerTest.java new file mode 100644 index 00000000..5726a9e6 --- /dev/null +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/DisabledControlEventListenerTest.java @@ -0,0 +1,170 @@ +package com.spinyowl.spinygui.core.system.event.listener; + +import static com.spinyowl.spinygui.core.node.NodeBuilder.attrs; +import static com.spinyowl.spinygui.core.node.NodeBuilder.button; +import static com.spinyowl.spinygui.core.node.NodeBuilder.disabled; +import static com.spinyowl.spinygui.core.node.NodeBuilder.frame; +import static com.spinyowl.spinygui.core.node.NodeBuilder.textarea; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableSet; +import com.spinyowl.spinygui.core.event.MouseClickEvent; +import com.spinyowl.spinygui.core.event.processor.EventProcessor; +import com.spinyowl.spinygui.core.input.Keyboard; +import com.spinyowl.spinygui.core.input.MouseService; +import com.spinyowl.spinygui.core.input.MouseService.CursorPositions; +import com.spinyowl.spinygui.core.node.ButtonElement; +import com.spinyowl.spinygui.core.node.Frame; +import com.spinyowl.spinygui.core.node.TextareaElement; +import com.spinyowl.spinygui.core.system.event.SystemCharEvent; +import com.spinyowl.spinygui.core.system.event.SystemKeyEvent; +import com.spinyowl.spinygui.core.system.event.SystemMouseClickEvent; +import com.spinyowl.spinygui.core.system.input.SystemKeyAction; +import com.spinyowl.spinygui.core.system.input.SystemMouseButton; +import com.spinyowl.spinygui.core.time.TimeService; +import org.joml.Vector2f; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DisabledControlEventListenerTest { + + @Mock private EventProcessor eventProcessor; + @Mock private TimeService timeService; + @Mock private MouseService mouseService; + @Mock private Keyboard keyboard; + + @Test + void mousePressOnDisabledButtonDoesNotFocusPressOrDispatchEvents() { + ButtonElement button = button(attrs(disabled())); + button.box().contentSize(20, 20); + button.box().contentPosition(20, 20); + Frame frame = frame(button); + frame.box().contentSize(100, 100); + + Vector2f cursor = new Vector2f(25, 25); + when(mouseService.getCursorPositions(frame)).thenReturn(new CursorPositions(cursor, cursor)); + + SystemMouseClickEvent event = + SystemMouseClickEvent.builder() + .action(SystemKeyAction.PRESS) + .mods(ImmutableSet.of()) + .frame(frame) + .button(SystemMouseButton.LEFT) + .build(); + + var listener = + SystemMouseClickEventListener.builder() + .eventProcessor(eventProcessor) + .timeService(timeService) + .mouseService(mouseService) + .build(); + + listener.process(event, frame); + + verify(mouseService).pressed(event.button().mouseButton(), true); + verify(mouseService).getCursorPositions(frame); + verifyNoInteractions(eventProcessor, timeService); + assertFalse(button.focused()); + assertFalse(button.pressed()); + } + + @Test + void mouseReleaseOverDisabledButtonClearsPreviouslyPressedControl() { + ButtonElement disabledButton = button(attrs(disabled())); + disabledButton.box().contentSize(20, 20); + disabledButton.box().contentPosition(20, 20); + + ButtonElement focusedButton = button(); + focusedButton.focused(true); + focusedButton.pressed(true); + focusedButton.box().contentSize(20, 20); + focusedButton.box().contentPosition(50, 20); + + Frame frame = frame(disabledButton, focusedButton); + frame.box().contentSize(100, 100); + + Vector2f cursor = new Vector2f(25, 25); + when(mouseService.getCursorPositions(frame)).thenReturn(new CursorPositions(cursor, cursor)); + when(timeService.currentTime()).thenReturn(1D); + + SystemMouseClickEvent event = + SystemMouseClickEvent.builder() + .action(SystemKeyAction.RELEASE) + .mods(ImmutableSet.of()) + .frame(frame) + .button(SystemMouseButton.LEFT) + .build(); + + var listener = + SystemMouseClickEventListener.builder() + .eventProcessor(eventProcessor) + .timeService(timeService) + .mouseService(mouseService) + .build(); + + listener.process(event, frame); + + assertFalse(disabledButton.pressed()); + assertFalse(focusedButton.pressed()); + verify(eventProcessor).push(any(MouseClickEvent.class)); + } + + @Test + void keyPressOnFocusedDisabledButtonDoesNotActivateOrDispatchKeyboardEvent() { + ButtonElement button = button(attrs(disabled())); + button.focused(true); + button.pressed(true); + Frame frame = frame(button); + + SystemKeyEvent event = + SystemKeyEvent.builder() + .keyCode(13) + .scancode(13) + .action(SystemKeyAction.PRESS) + .mods(ImmutableSet.of()) + .frame(frame) + .build(); + + var listener = + SystemKeyEventListener.builder() + .eventProcessor(eventProcessor) + .timeService(timeService) + .keyboard(keyboard) + .build(); + + listener.process(event, frame); + + verifyNoInteractions(eventProcessor, timeService, keyboard); + assertFalse(button.pressed()); + } + + @Test + void charInputOnFocusedDisabledTextareaDoesNotEditOrDispatchCharEvent() { + TextareaElement textarea = textarea(attrs(disabled()), "ac"); + textarea.caretIndex(1); + textarea.focused(true); + Frame frame = frame(textarea); + + SystemCharEvent event = SystemCharEvent.builder().frame(frame).codepoint('b').build(); + + var listener = + SystemCharEventListener.builder() + .eventProcessor(eventProcessor) + .timeService(timeService) + .build(); + + listener.process(event, frame); + + assertEquals("ac", textarea.value()); + assertEquals(1, textarea.caretIndex()); + verifyNoInteractions(eventProcessor, timeService); + } +}