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


Comments:

Patch:
diff --git a/src/twisted/conch/insults/insults.py b/src/twisted/conch/insults/insults.py
index 4640aab368..ff5edf45b0 100644
--- a/src/twisted/conch/insults/insults.py
+++ b/src/twisted/conch/insults/insults.py
@@ -431,23 +431,7 @@ _KEY_NAMES = (
     "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
index c93fae7b21..da0fc1e08e 100644
--- a/src/twisted/conch/insults/window.py
+++ b/src/twisted/conch/insults/window.py
@@ -6,6 +6,8 @@ Simple insults-based widget library
 @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 @@ class Widget:
         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)
 
diff --git a/src/twisted/conch/newsfragments/12046.bugfix b/src/twisted/conch/newsfragments/12046.bugfix
new file mode 100644
index 0000000000..2f9b269f0d
--- /dev/null
+++ b/src/twisted/conch/newsfragments/12046.bugfix
@@ -0,0 +1,1 @@
+twisted.conch.insults.window.Widget.functionKeyReceived now dispatches functional key events to corresponding `func_KEYNAME` methods, where `KEYNAME` can be `F1`, `F2`, `HOME`, `UP_ARROW` etc. This is a regression introduced with #8214 in Twisted 16.5.0, where events changed from `const` objects to bytestrings in square brackets like `[F1]`.
\ No newline at end of file


Test patch:
diff --git a/src/twisted/conch/test/test_window.py b/src/twisted/conch/test/test_window.py
index bcb0755a0a..9bfb53ee47 100644
--- a/src/twisted/conch/test/test_window.py
+++ b/src/twisted/conch/test/test_window.py
@@ -5,7 +5,14 @@ from __future__ import annotations
 
 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 @@ class ScrolledAreaTests(TestCase):
         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")