Repository: twisted/twisted

Issue:
Conch widgets are ignoring function keys
**Describe the incorrect behavior you saw**
`conch.insults.window.Widget` objects are ignoring function keys

**Describe how to cause this behavior**
Happens with any `Widget`, most probably caused in 2016 with these two commits:

6659c6b4f0290b83d5978e93ed8aff3d4878f649 changed how `FUNCTION_KEYS` are defined (from `const` instances to `bytes`) in `twisted.conch.insults.insults`
5d2ff6938cd1fcfce9e0861ae2c3a562c21b8156 changed how `Widget.functionKeyReceived` handles them in `twisted.conch.insults.window`

This is not covered by tests, so it slipped under radar. Looks like I'm first user in years. Even demos included in docs are bit rusty, so here is somewhat stripped gist -> [`window.tac`](https://gist.github.com/Menyadar/09e989c56b23f14386f3c8224b551c3d). Focus "Selection" widgets (there are two of them - columns with numbers, jump to them with `tab` key) and try to change the selection using arrow keys or page up/down.

**Describe the correct behavior you'd like to see**
Movement should just work

**Testing environment**
 - Operating System and Version; paste the output of these commands:
```
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.3 LTS"
```
 - Twisted version [e.g. 22.2.0]
```
$ twist --version
23.10.0.post0
```

**Additional context**
Fix is trivial, PR on the way



Discussion:

Resolving patch:
diff --git a/src/twisted/conch/insults/insults.py b/src/twisted/conch/insults/insults.py
--- a/src/twisted/conch/insults/insults.py
+++ b/src/twisted/conch/insults/insults.py
@@ -431,23 +431,7 @@ def log(s):
     "CONTROL",
 )
 
-
-class _const:
-    """
-    @ivar name: A string naming this constant
-    """
-
-    def __init__(self, name: str) -> None:
-        self.name = name
-
-    def __repr__(self) -> str:
-        return "[" + self.name + "]"
-
-    def __bytes__(self) -> bytes:
-        return ("[" + self.name + "]").encode("ascii")
-
-
-FUNCTION_KEYS = [_const(_name).__bytes__() for _name in _KEY_NAMES]
+FUNCTION_KEYS = [f"[{_name}]".encode("ascii") for _name in _KEY_NAMES]
 
 
 @implementer(ITerminalTransport)
diff --git a/src/twisted/conch/insults/window.py b/src/twisted/conch/insults/window.py
--- a/src/twisted/conch/insults/window.py
+++ b/src/twisted/conch/insults/window.py
@@ -6,6 +6,8 @@
 @author: Jp Calderone
 """
 
+from __future__ import annotations
+
 import array
 
 from twisted.conch.insults import helper, insults
@@ -47,7 +49,8 @@ class Widget:
     focused = False
     parent = None
     dirty = False
-    width = height = None
+    width: int | None = None
+    height: int | None = None
 
     def repaint(self):
         if not self.dirty:
@@ -109,7 +112,12 @@ def functionKeyReceived(self, keyID, modifier):
         name = keyID
         if not isinstance(keyID, str):
             name = name.decode("utf-8")
-        func = getattr(self, "func_" + name, None)
+
+        # Peel off the square brackets added by the computed definition of
+        # twisted.conch.insults.insults.FUNCTION_KEYS.
+        methodName = "func_" + name[1:-1]
+
+        func = getattr(self, methodName, None)
         if func is not None:
             func(modifier)
 


Test patch:
diff --git a/src/twisted/conch/test/test_window.py b/src/twisted/conch/test/test_window.py
--- a/src/twisted/conch/test/test_window.py
+++ b/src/twisted/conch/test/test_window.py
@@ -5,7 +5,14 @@
 
 from typing import Callable
 
-from twisted.conch.insults.window import ScrolledArea, TextOutput, TopWindow
+from twisted.conch.insults.insults import ServerProtocol
+from twisted.conch.insults.window import (
+    ScrolledArea,
+    Selection,
+    TextOutput,
+    TopWindow,
+    Widget,
+)
 from twisted.trial.unittest import TestCase
 
 
@@ -66,3 +73,100 @@ def test_parent(self) -> None:
         scrolled = ScrolledArea(widget)
         self.assertIs(widget.parent, scrolled._viewport)
         self.assertIs(scrolled._viewport.parent, scrolled)
+
+
+class SelectionTests(TestCase):
+    """
+    Change focused entry in L{Selection} using function keys.
+    """
+
+    def setUp(self) -> None:
+        """
+        Create L{ScrolledArea} widget with 10 elements and position selection to 5th element.
+        """
+        seq: list[bytes] = [f"{_num}".encode("ascii") for _num in range(10)]
+        self.widget = Selection(seq, None)
+        self.widget.height = 10
+        self.widget.focusedIndex = 5
+
+    def test_selectionDownArrow(self) -> None:
+        """
+        Send DOWN_ARROW to select element just below the current one.
+        """
+        self.widget.keystrokeReceived(ServerProtocol.DOWN_ARROW, None)  # type: ignore[attr-defined]
+        self.assertIs(self.widget.focusedIndex, 6)
+
+    def test_selectionUpArrow(self) -> None:
+        """
+        Send UP_ARROW to select element just above the current one.
+        """
+        self.widget.keystrokeReceived(ServerProtocol.UP_ARROW, None)  # type: ignore[attr-defined]
+        self.assertIs(self.widget.focusedIndex, 4)
+
+    def test_selectionPGDN(self) -> None:
+        """
+        Send PGDN to select element one page down (here: last element).
+        """
+        self.widget.keystrokeReceived(ServerProtocol.PGDN, None)  # type: ignore[attr-defined]
+        self.assertIs(self.widget.focusedIndex, 9)
+
+    def test_selectionPGUP(self) -> None:
+        """
+        Send PGUP to select element one page up (here: first element).
+        """
+        self.widget.keystrokeReceived(ServerProtocol.PGUP, None)  # type: ignore[attr-defined]
+        self.assertIs(self.widget.focusedIndex, 0)
+
+
+class RecordingWidget(Widget):
+    """
+    A dummy Widget implementation to test handling of function keys by
+    recording keyReceived events.
+    """
+
+    def __init__(self) -> None:
+        Widget.__init__(self)
+        self.triggered: list[str] = []
+
+    def func_F1(self, modifier: str) -> None:
+        self.triggered.append("F1")
+
+    def func_HOME(self, modifier: str) -> None:
+        self.triggered.append("HOME")
+
+    def func_DOWN_ARROW(self, modifier: str) -> None:
+        self.triggered.append("DOWN_ARROW")
+
+    def func_UP_ARROW(self, modifier: str) -> None:
+        self.triggered.append("UP_ARROW")
+
+    def func_PGDN(self, modifier: str) -> None:
+        self.triggered.append("PGDN")
+
+    def func_PGUP(self, modifier: str) -> None:
+        self.triggered.append("PGUP")
+
+
+class WidgetFunctionKeyTests(TestCase):
+    """
+    Call functionKeyReceived with key values from insults.ServerProtocol
+    """
+
+    def test_functionKeyReceivedDispatch(self) -> None:
+        """
+        L{Widget.functionKeyReceived} dispatches its input, a constant on
+        ServerProtocol, to a matched C{func_KEY} method.
+        """
+        widget = RecordingWidget()
+
+        def checkOneKey(key: str) -> None:
+            widget.functionKeyReceived(getattr(ServerProtocol, key), None)
+            self.assertEqual([key], widget.triggered)
+            widget.triggered.clear()
+
+        checkOneKey("F1")
+        checkOneKey("HOME")
+        checkOneKey("DOWN_ARROW")
+        checkOneKey("UP_ARROW")
+        checkOneKey("PGDN")
+        checkOneKey("PGUP")