From 6d2436cde11f7f0dd052b4c25de4ff45efe68765 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Sun, 6 Sep 2026 12:26:00 -0700 Subject: [PATCH] Let the map brightness reach the map, and put the options in groups The map brightness setting could not make the map visible on a vector theme, which is the one place it was needed. Those themes want the ground well out of the way -- a tinted photograph of a county behind the vectors is the one thing that stops a vector display looking like one -- and that was done by multiplying the setting by about a quarter. A multiplier is a ceiling: turned the whole way up, the setting still gave a map at a tenth the brightness the default theme gives, which is to say invisible, and no amount of turning it up did anything about that. It is a curve now rather than a ceiling. The theme raises the setting to a power, so the middle of the range is still quiet -- seventy per cent lands where the old quarter did, which is the look these themes are for -- and the top of the range is a full-brightness map on every theme there is. On the green phosphor the setting now spans a luminance of six to seventy where it used to stop at twenty-one. And the options are in six groups rather than one list: receiver, listening, aircraft, animation, the map, labels. Thirty-three of them on one screen is a wall rather than a menu. A number opens a group and a number inside it changes an option, with the numbers still being each option's place in the whole list so that the same number means the same option wherever it is typed -- which meant reordering the list so that every group is contiguous, and there is a test that says so. A group menu makes a known option harder to reach than a flat list did, so the name works too: typing "map brightness" at the top goes straight to it, and part of a name lists everything it could mean. A name that matches exactly wins outright, so "speed" reaches the setting called speed rather than that one and every other whose description happens to mention the word. One thing to know: a bare number at the top of the menu now opens a group where it used to edit the option of that number. The tests that drove the menu that way would have gone on silently editing whatever option shared the number, so they ask by name now, and one of them checks that a group number changes nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg --- README.md | 39 +++- bandsaunter/__init__.py | 2 +- bandsaunter/aircraft.py | 416 ++++++++++++++++++------------------ bandsaunter/flightmap.py | 12 +- bandsaunter/themes.py | 26 ++- bandsaunter/tui.py | 125 ++++++++++- packaging/bandsaunter.1 | 14 +- packaging/make-man.py | 12 ++ tests/test_aircraft_menu.py | 117 +++++++++- tests/test_themes.py | 27 ++- 10 files changed, 540 insertions(+), 250 deletions(-) diff --git a/README.md b/README.md index 310264b..089b474 100644 --- a/README.md +++ b/README.md @@ -1125,11 +1125,30 @@ tried, since distributions disagree about which to package. Without any of them you lose this window and nothing else, and the program says how to get one rather than failing. -**Or from the menus: `bandsaunter` → 5, Aircraft (ADS-B).** Every option is -on one screen with what it does beside it, `?N` explains any of them at -length, `p` starts a passive capture, `r` opens the realtime window and `m` -draws a map from a log — no flags to remember, and the options can be saved -as the default. +**Or from the menus: `bandsaunter` → 5, Aircraft (ADS-B).** `p` starts a +passive capture, `r` opens the realtime window and `m` draws a map from a log +— no flags to remember, and the options can be saved as the default. + +The options are in six groups rather than one list, because thirty-three of +them on one screen is a wall rather than a menu: + +| | | | +| --- | --- | --- | +| **receiver** | the dongle, and where it is standing | device, gain, sample rate, position, the simulated sky | +| **listening** | what one session does | how long, the log, KML, how long an aircraft stays up, draw when finished | +| **aircraft** | who they are | the registers, the schedule services, rechecking positions | +| **animation** | the moving picture | kind, length, speed, frame rate, width, trails, fading | +| **the map** | what is under and around them | tiles, theme, brightness, radius, aerodromes, range rings | +| **labels** | what is written beside them | the labels themselves, and the unit | + +A number opens a group; inside it, a number changes an option and `?N` +explains any of them at length. The numbers are the option's place in the +whole list, so the same number means the same option wherever it is typed. + +**Or type the name.** `map brightness` at the top of the menu goes straight to +that option, and part of a name lists everything it could mean. A name that +matches exactly wins outright, so `speed` reaches the setting called speed +rather than that one and every other whose description mentions the word. > **This is not a scan, and the band plan's `adsb` preset will not do it.** > Sweeping 1090 MHz records the bursts as clicks in a WAV file and decodes @@ -1542,7 +1561,15 @@ a thousand tiles from for one picture. A smaller `--radius` buys the detail back, since the same budget then covers less ground. `--map-brightness PERCENT` (70 by default) is how far up its range the map is -drawn. The ground has to stay dark enough that the aircraft are the brightest +drawn, and **the vector themes bend the middle of that range down hard** — +because a tinted photograph of a county behind the vectors is the one thing +that stops a vector display looking like one. It is a curve, not a ceiling: +the top of the setting is a full-brightness map on every theme. It used to be +a plain multiplier, which meant that on a phosphor theme the setting could not +reach a visible map at all — turned the whole way up it still came out at a +tenth of what the default theme gives, which is to say invisible. So if the +ground is too faint to make out, this is the setting that fixes it, and on a +vector theme it takes rather more turning up than on the default one. The ground has to stay dark enough that the aircraft are the brightest thing on the picture and light enough that a coastline can be made out at all, and which way to err depends on the screen you are looking at. In the window, `[` and `]` change it while it runs. diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 6bbaf9c..b286553 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -9,7 +9,7 @@ and transcribing speech. # 2026-08-21_02 is the second build made on the 21st. The revision is padded # to two digits so versions sort as text. VERSION_DATE = "2026-09-06" -VERSION_REVISION = 1 +VERSION_REVISION = 2 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aircraft.py b/bandsaunter/aircraft.py index d75fb8f..3694aa9 100644 --- a/bandsaunter/aircraft.py +++ b/bandsaunter/aircraft.py @@ -143,7 +143,47 @@ class AircraftOptions: O = Setting -OPTIONS: tuple[Setting, ...] = ( +OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int", + "which receiver to use, when more than one is plugged in", + "The index shown by `bandsaunter devices`. Zero unless you have " + "several dongles.", + minimum=0, flags=("--device",), example="0"), + O("gain", "Gain", "Receiver", "gain", + "tuner gain in dB, or automatic", + "ADS-B is a weak burst from a long way off, and the automatic gain " + "control usually does well enough. A fixed high gain can hear more " + "where there is nothing strong nearby to overload the front end.", + flags=("--gain",), example="auto"), + O("rate", "Sample rate", "Receiver", "float", + "how fast to sample; two megasamples a second is the minimum", + "One microsecond per bit means two samples per bit at 2 MS/s, which is " + "the least that can read one. Higher rates decode a little more of the " + "weak traffic and cost proportionally more processing.", + unit="Hz", minimum=float(SAMPLE_RATE), flags=("--rate",), + example="2000000"), + O("location", "Receiver at", "Receiver", "text", + "where the receiver is, as latitude,longitude (blank = work it out)", + "The centre of the map. Left blank it is taken from the middle of " + "everything heard, which is very close to right: a receiver hears " + "aircraft all around it, and the middle is a median rather than an " + "average, so a handful of wrong positions cannot drag it anywhere. " + "Setting it explicitly is worth doing if you want the same frame every " + "night regardless of which way the traffic went.", + example="32.54,-111.17", metavar="LAT,LON"), + O("simulate", "Invent a sky", "Receiver", "bool", + "fly imaginary aircraft past an imaginary receiver", + "Six aircraft that are not there, broadcasting real frames with real " + "checksums through the real decoder. Nothing touches the receiver, so " + "the log, the lookups, the report and the map can all be tried before " + "an aerial exists.", + flags=("--simulate",), + guidance="Turn this on to see what the whole thing does without " + "hardware. Turn it off to hear real aircraft."), + O("near", "Imaginary sky near", "Receiver", "text", + "where the simulated aircraft are flying", + "Latitude and longitude, as two numbers. Only used when the sky is " + "invented; it decides where the map ends up centred.", + flags=("--near",), example="47.55,-122.30", metavar="LAT,LON"), # -- listening ------------------------------------------------------ O("seconds", "Listen for", "Listening", "float", "how long to listen before stopping (0 = until interrupted)", @@ -154,38 +194,6 @@ OPTIONS: tuple[Setting, ...] = ( unit="s", minimum=0.0, flags=("--seconds",), example="600", guidance="Sixty seconds is enough to know whether aircraft are being " "heard. An evening of traffic wants no limit."), - O("simulate", "Invent a sky", "Listening", "bool", - "fly imaginary aircraft past an imaginary receiver", - "Six aircraft that are not there, broadcasting real frames with real " - "checksums through the real decoder. Nothing touches the receiver, so " - "the log, the lookups, the report and the map can all be tried before " - "an aerial exists.", - flags=("--simulate",), - guidance="Turn this on to see what the whole thing does without " - "hardware. Turn it off to hear real aircraft."), - O("near", "Imaginary sky near", "Listening", "text", - "where the simulated aircraft are flying", - "Latitude and longitude, as two numbers. Only used when the sky is " - "invented; it decides where the map ends up centred.", - flags=("--near",), example="47.55,-122.30", metavar="LAT,LON"), - O("device", "Receiver", "Listening", "int", - "which receiver to use, when more than one is plugged in", - "The index shown by `bandsaunter devices`. Zero unless you have " - "several dongles.", - minimum=0, flags=("--device",), example="0"), - O("gain", "Gain", "Listening", "gain", - "tuner gain in dB, or automatic", - "ADS-B is a weak burst from a long way off, and the automatic gain " - "control usually does well enough. A fixed high gain can hear more " - "where there is nothing strong nearby to overload the front end.", - flags=("--gain",), example="auto"), - O("rate", "Sample rate", "Listening", "float", - "how fast to sample; two megasamples a second is the minimum", - "One microsecond per bit means two samples per bit at 2 MS/s, which is " - "the least that can read one. Higher rates decode a little more of the " - "weak traffic and cost proportionally more processing.", - unit="Hz", minimum=float(SAMPLE_RATE), flags=("--rate",), - example="2000000"), O("frames", "Show every frame", "Listening", "bool", "print each frame as it arrives, rather than a running count", "Every frame, with what was read out of it. Useful once, to see that " @@ -197,30 +205,6 @@ OPTIONS: tuple[Setting, ...] = ( "it holds the raw hexadecimal of every frame beside what was decoded " "from it. Turning this off leaves nothing behind but the screen.", flags=("--log",), off_flags=("--no-log",)), - O("lookup", "Look the aircraft up", "Listening", "bool", - "ask the public registers who each aircraft is", - "Two registers are asked -- adsbdb, then hexdb -- for the " - "registration, the type, the operator and the route, and the answers " - "are cached for a month. Only the address and callsign heard on the " - "air are ever sent. What the address block and the callsign say on " - "their own is worked out offline either way.", - flags=("--lookup",), off_flags=("--no-lookup",)), - O("schedules", "Schedule services", "Listening", "text", - "which paid schedule services to ask, in order (blank = all with keys)", - "A callsign is a flight number and an airline runs the same number over " - "several legs in a day, so the free route databases -- which hold one " - "route per number -- often name somebody else's leg. A commercial " - "schedule service holds the timetable and the day's movements and can " - "say which leg is in the air now. Four are wired up: flightaware, " - "flightradar24, oag and cirium. Each wants a key, none is required, " - "and a service with no key is skipped in silence. Keys are read from " - "the environment rather than kept here, because a settings file gets " - "copied between machines and pasted into messages asking for help: " - "BANDSAUNTER_AEROAPI_KEY, BANDSAUNTER_FR24_TOKEN, BANDSAUNTER_OAG_KEY, " - "and BANDSAUNTER_CIRIUM_APP_ID with BANDSAUNTER_CIRIUM_APP_KEY.", - example="flightaware,cirium", - guidance="Leave it blank unless you want one service tried before " - "another. With no keys set, nothing changes."), O("kml", "Also write a KML", "Listening", "bool", "write the flight paths for Google Earth as well", "One line per aircraft on the globe, with a pin where it was last " @@ -237,102 +221,35 @@ OPTIONS: tuple[Setting, ...] = ( unit="s", minimum=1.0, example="45", guidance="Long enough that a gap in reception does not make rows jump " "about; short enough that the screen is the sky now."), - O("speed_unit", "Speed in", "Listening", "choice", - "what to show speeds and distances in", - "Aircraft broadcast knots and the log keeps knots, because that is " - "what the standard sends; this is the unit they are shown in. It " - "changes the heading of the live display, the speeds written beside " - "each aircraft on the map and in the report, and the distance unit " - "that goes with them -- nautical miles with knots, statute miles with " - "miles an hour, kilometres with km/h, so that one picture never " - "carries two different miles.", - choices=("knots", "mph", "kph"), - guidance="knots is what aviation uses and what the aircraft actually " - "said. mph or kph if that is what means something to you."), O("draw_after", "Draw when finished", "Listening", "bool", "draw the map as soon as the listening stops", "Saves running the map separately. It uses the drawing options below.", flags=("--map",)), - - # -- drawing -------------------------------------------------------- - O("picture", "Picture", "Drawing", "choice", - "what kind of picture to draw", - "gif is an animation that plays anywhere and needs nothing installed. " - "mp4 is smaller and smoother but needs ffmpeg. png is one still " - "picture of the whole session, every path drawn at once.", - choices=("gif", "mp4", "png"), flags=("--out",), - guidance="Start with gif. Use png when you want one picture to look at " - "or send."), - O("length", "Animation length", "Drawing", "float", - "how long the animation should run for", - "The whole session is fitted into this many seconds, so an evening of " - "flying plays in half a minute. Ignored when a speed is given.", - unit="s", minimum=1.0, flags=("--seconds",), example="30"), - O("speed", "Speed", "Drawing", "float", - "seconds of flying per second of animation (0 = fit to the length)", - "60 means a minute of real flying every second. Setting this overrides " - "the length above: a long session simply makes a longer animation.", - unit="x", minimum=0.0, flags=("--speed",), example="60"), - O("fps", "Frames a second", "Drawing", "float", - "how many frames of animation each second holds", - "Twelve is smooth enough for aircraft, which do not move quickly on a " - "map. A GIF can only hold whole hundredths of a second per frame, so " - "the real rate is rounded to the nearest one it can express.", - minimum=1.0, flags=("--fps",), example="12"), - O("width", "Picture width", "Drawing", "int", - "how many pixels across the picture is", - "The height follows from the shape of the area the aircraft covered, " - "so that a mile across looks like a mile up the picture.", - unit="px", minimum=160, flags=("--width",), example="960"), - O("trail", "Trail", "Drawing", "float", - "how much of the path to leave behind each aircraft (0 = all of it)", - "The whole flight is drawn by default, which is what makes the picture " - "a map of the evening rather than a snapshot. A number of seconds " - "leaves a comet tail instead, which is easier to follow when many " - "aircraft cross the same piece of sky.", - unit="s", minimum=0.0, flags=("--trail",), example="0"), - O("fade", "Fade out over", "Drawing", "float", - "how long an aircraft takes to fade away once it has gone quiet", - "An aircraft that stops transmitting has not stopped existing, and " - "taking it off the picture between one frame and the next says that it " - "did. Instead it is left where it was last actually seen and fades from " - "there, which reads as an aircraft going quiet rather than as a blink. " - "Nothing is invented by it: the fading happens at the last known " - "position, never at a reckoned one, because the reason for giving up on " - "an aircraft in the first place is that where it would be by now is a " - "guess. Zero takes it away the moment it is given up on.", - unit="s", minimum=0.0, flags=("--fade",), example="20", - guidance="Long enough to notice, short enough that a busy sky is not " - "half ghosts."), - O("stale", "Forget after", "Drawing", "float", - "stop drawing an aircraft this long after its last report", - "Between reports an aircraft is dead-reckoned from the speed and " - "heading it last gave. After a few minutes of that it has flown fifty " - "miles on a guess, so it is dropped instead of invented.", - unit="s", minimum=1.0, flags=("--stale",), example="300"), - O("radius", "Map radius", "Drawing", "float", - "how far around the receiver the map reaches (0 = fit whatever was heard)", - "An aerial hears a hundred miles on a good day, and a position that " - "decoded wrongly can land anywhere on Earth. A map drawn to fit " - "everything heard is therefore drawn to fit the mistakes: the aircraft " - "come out a pixel wide in the middle of an empty continent. This frames " - "the picture on the receiver instead, so the scale stays the same from " - "one evening to the next and anything further out is left off the edge. " - "In the same unit as the speeds -- nautical miles with knots, statute " - "miles with mph, kilometres with kph.", - minimum=0.0, flags=("--radius",), example="100", - guidance="Set it to what your aerial can really hear. Zero goes back " - "to fitting whatever turned up, mistakes and all."), - O("location", "Receiver at", "Drawing", "text", - "where the receiver is, as latitude,longitude (blank = work it out)", - "The centre of the map. Left blank it is taken from the middle of " - "everything heard, which is very close to right: a receiver hears " - "aircraft all around it, and the middle is a median rather than an " - "average, so a handful of wrong positions cannot drag it anywhere. " - "Setting it explicitly is worth doing if you want the same frame every " - "night regardless of which way the traffic went.", - example="32.54,-111.17", metavar="LAT,LON"), - O("recheck", "Check the positions", "Drawing", "bool", + O("lookup", "Look the aircraft up", "Aircraft", "bool", + "ask the public registers who each aircraft is", + "Two registers are asked -- adsbdb, then hexdb -- for the " + "registration, the type, the operator and the route, and the answers " + "are cached for a month. Only the address and callsign heard on the " + "air are ever sent. What the address block and the callsign say on " + "their own is worked out offline either way.", + flags=("--lookup",), off_flags=("--no-lookup",)), + O("schedules", "Schedule services", "Aircraft", "text", + "which paid schedule services to ask, in order (blank = all with keys)", + "A callsign is a flight number and an airline runs the same number over " + "several legs in a day, so the free route databases -- which hold one " + "route per number -- often name somebody else's leg. A commercial " + "schedule service holds the timetable and the day's movements and can " + "say which leg is in the air now. Four are wired up: flightaware, " + "flightradar24, oag and cirium. Each wants a key, none is required, " + "and a service with no key is skipped in silence. Keys are read from " + "the environment rather than kept here, because a settings file gets " + "copied between machines and pasted into messages asking for help: " + "BANDSAUNTER_AEROAPI_KEY, BANDSAUNTER_FR24_TOKEN, BANDSAUNTER_OAG_KEY, " + "and BANDSAUNTER_CIRIUM_APP_ID with BANDSAUNTER_CIRIUM_APP_KEY.", + example="flightaware,cirium", + guidance="Leave it blank unless you want one service tried before " + "another. With no keys set, nothing changes."), + O("recheck", "Check the positions", "Aircraft", "bool", "throw out positions the aircraft could not have been in", "For logs recorded before the decoder checked how old the two halves " "of a position were. A compact-position report is half a position: an " @@ -347,38 +264,83 @@ OPTIONS: tuple[Setting, ...] = ( guidance="Worth turning on for anything recorded before this version. " "Newer logs have the check applied as they are written, so it " "finds almost nothing."), - O("airports", "Mark the airports", "Drawing", "bool", - "mark every aerodrome on the map, not only the ones flown between", - "A route names the two airports its aircraft is flying between, and " - "those are almost never the ones underneath: a receiver hears aircraft " - "over its own county, and the county's airports are what say where on " - "the map you are looking. They are asked for once per area from the " - "same map data the tiles are drawn from, and kept on disk for a month " - "afterwards, since a runway does not move.", - flags=("--airports",), off_flags=("--no-airports",), - guidance="Turn it off for a picture with nothing but the aircraft on " - "it, or where there is no network and nothing cached."), - O("rings", "Range rings on the pictures", "Drawing", "bool", - "faint discs at a quarter, a half and three quarters of the radius", - "Concentric on the receiver and translucent, so that they stack: the " - "ground inside the innermost is lifted three times, the next twice, " - "the outer once. What that gives is a sense of how far away a thing " - "is without measuring anything -- an aircraft two shades in is about " - "halfway to the edge of what this receiver hears. Each is labelled " - "with its distance. They need a receiver position and a radius, and " - "are not drawn without both.", - flags=("--rings",), off_flags=("--no-rings",), - guidance="Turn it off for a picture with nothing on it but the " - "aircraft and the ground."), - O("window_rings", "Range rings in the window", "Drawing", "bool", - "the same discs on the realtime display", - "The same rings as the pictures get, on the window instead. They are " - "separate settings because the two are looked at differently: a " - "picture is studied and a window is glanced at, and the rings help " - "one more than the other depending on which you are doing.", - flags=("--window-rings",), off_flags=("--no-window-rings",), - guidance="Turn it off if the window is busy enough already."), - O("theme", "Colour theme", "Drawing", "choice", + # -- drawing -------------------------------------------------------- + O("picture", "Picture", "Animation", "choice", + "what kind of picture to draw", + "gif is an animation that plays anywhere and needs nothing installed. " + "mp4 is smaller and smoother but needs ffmpeg. png is one still " + "picture of the whole session, every path drawn at once.", + choices=("gif", "mp4", "png"), flags=("--out",), + guidance="Start with gif. Use png when you want one picture to look at " + "or send."), + O("length", "Animation length", "Animation", "float", + "how long the animation should run for", + "The whole session is fitted into this many seconds, so an evening of " + "flying plays in half a minute. Ignored when a speed is given.", + unit="s", minimum=1.0, flags=("--seconds",), example="30"), + O("speed", "Speed", "Animation", "float", + "seconds of flying per second of animation (0 = fit to the length)", + "60 means a minute of real flying every second. Setting this overrides " + "the length above: a long session simply makes a longer animation.", + unit="x", minimum=0.0, flags=("--speed",), example="60"), + O("fps", "Frames a second", "Animation", "float", + "how many frames of animation each second holds", + "Twelve is smooth enough for aircraft, which do not move quickly on a " + "map. A GIF can only hold whole hundredths of a second per frame, so " + "the real rate is rounded to the nearest one it can express.", + minimum=1.0, flags=("--fps",), example="12"), + O("width", "Picture width", "Animation", "int", + "how many pixels across the picture is", + "The height follows from the shape of the area the aircraft covered, " + "so that a mile across looks like a mile up the picture.", + unit="px", minimum=160, flags=("--width",), example="960"), + O("trail", "Trail", "Animation", "float", + "how much of the path to leave behind each aircraft (0 = all of it)", + "The whole flight is drawn by default, which is what makes the picture " + "a map of the evening rather than a snapshot. A number of seconds " + "leaves a comet tail instead, which is easier to follow when many " + "aircraft cross the same piece of sky.", + unit="s", minimum=0.0, flags=("--trail",), example="0"), + O("fade", "Fade out over", "Animation", "float", + "how long an aircraft takes to fade away once it has gone quiet", + "An aircraft that stops transmitting has not stopped existing, and " + "taking it off the picture between one frame and the next says that it " + "did. Instead it is left where it was last actually seen and fades from " + "there, which reads as an aircraft going quiet rather than as a blink. " + "Nothing is invented by it: the fading happens at the last known " + "position, never at a reckoned one, because the reason for giving up on " + "an aircraft in the first place is that where it would be by now is a " + "guess. Zero takes it away the moment it is given up on.", + unit="s", minimum=0.0, flags=("--fade",), example="20", + guidance="Long enough to notice, short enough that a busy sky is not " + "half ghosts."), + O("stale", "Forget after", "Animation", "float", + "stop drawing an aircraft this long after its last report", + "Between reports an aircraft is dead-reckoned from the speed and " + "heading it last gave. After a few minutes of that it has flown fifty " + "miles on a guess, so it is dropped instead of invented.", + unit="s", minimum=1.0, flags=("--stale",), example="300"), + O("basemap", "Map underneath", "The map", "bool", + "draw a real map under the flight paths", + "A flight path over a black rectangle says how the aircraft moved and " + "nothing about where it was; over a coastline it says which airport it " + "left. The map is fetched from a standard tile server the first time an " + "area is drawn and kept on the disk afterwards, so drawing the same " + "evening again costs nothing and needs no network. A few dozen tiles " + "at most, dimmed so the aircraft stay the brightest thing on the " + "picture, and the credit the tiles require is written on it.", + flags=("--basemap",), off_flags=("--no-basemap",), + guidance="Turn it off for a picture with nothing but the tracks on it, " + "or where there is no network and no cached tiles."), + O("tile_url", "Tile server", "The map", "text", + "where the map tiles come from", + "Any server that serves 256-pixel tiles as {z}/{x}/{y}.png will do, " + "including one of your own. The default is the standard " + "OpenStreetMap one, whose tiles are free to use within its usage " + "policy: identify yourself, cache what you fetch, and do not bulk " + "download. This program does all three.", + example="https://tile.openstreetmap.org/{z}/{x}/{y}.png"), + O("theme", "Colour theme", "The map", "choice", "how the map looks: the colours, and whether the lines glow", "The default draws a night-blue ground with height as colour, low " "warm to high cold, which is what every other aircraft map does and " @@ -393,46 +355,88 @@ OPTIONS: tuple[Setting, ...] = ( choices=("night", "digital", "phosphor", "amber", "red"), flags=("--theme",), metavar="NAME", example="phosphor", guidance="night to read it, the others to look at it."), - O("map_brightness", "Map brightness", "Drawing", "int", + O("map_brightness", "Map brightness", "The map", "int", "how bright the map under the aircraft is drawn, as a percentage", "The map is the ground, not the subject, so it is drawn dark enough " "that the aircraft and their trails stay the brightest things on the " "picture. Too dark and a coastline cannot be made out at all; too " "bright and a city washes out the aircraft crossing it. This is which " - "way to err on the screen you are actually looking at.", + "way to err on the screen you are actually looking at. The vector " + "themes bend the middle of this range down hard, because a tinted " + "photograph of a county behind the vectors is the one thing that " + "stops a vector display looking like one -- but the top of the range " + "is a full-brightness map on every theme, so if the ground is barely " + "visible this is the setting that fixes it.", unit="%", minimum=10, maximum=100, flags=("--map-brightness",), metavar="PERCENT", example="70", guidance="Turn it up until the coast and the roads are readable, and " - "no further."), - O("basemap", "Map underneath", "Drawing", "bool", - "draw a real map under the flight paths", - "A flight path over a black rectangle says how the aircraft moved and " - "nothing about where it was; over a coastline it says which airport it " - "left. The map is fetched from a standard tile server the first time an " - "area is drawn and kept on the disk afterwards, so drawing the same " - "evening again costs nothing and needs no network. A few dozen tiles " - "at most, dimmed so the aircraft stay the brightest thing on the " - "picture, and the credit the tiles require is written on it.", - flags=("--basemap",), off_flags=("--no-basemap",), - guidance="Turn it off for a picture with nothing but the tracks on it, " - "or where there is no network and no cached tiles."), - O("tile_url", "Tile server", "Drawing", "text", - "where the map tiles come from", - "Any server that serves 256-pixel tiles as {z}/{x}/{y}.png will do, " - "including one of your own. The default is the standard " - "OpenStreetMap one, whose tiles are free to use within its usage " - "policy: identify yourself, cache what you fetch, and do not bulk " - "download. This program does all three.", - example="https://tile.openstreetmap.org/{z}/{x}/{y}.png"), - O("labels", "Label the aircraft", "Drawing", "bool", + "no further. On a vector theme it takes rather more turning " + "up than on the default one."), + O("radius", "Map radius", "The map", "float", + "how far around the receiver the map reaches (0 = fit whatever was heard)", + "An aerial hears a hundred miles on a good day, and a position that " + "decoded wrongly can land anywhere on Earth. A map drawn to fit " + "everything heard is therefore drawn to fit the mistakes: the aircraft " + "come out a pixel wide in the middle of an empty continent. This frames " + "the picture on the receiver instead, so the scale stays the same from " + "one evening to the next and anything further out is left off the edge. " + "In the same unit as the speeds -- nautical miles with knots, statute " + "miles with mph, kilometres with kph.", + minimum=0.0, flags=("--radius",), example="100", + guidance="Set it to what your aerial can really hear. Zero goes back " + "to fitting whatever turned up, mistakes and all."), + O("airports", "Mark the airports", "The map", "bool", + "mark every aerodrome on the map, not only the ones flown between", + "A route names the two airports its aircraft is flying between, and " + "those are almost never the ones underneath: a receiver hears aircraft " + "over its own county, and the county's airports are what say where on " + "the map you are looking. They are asked for once per area from the " + "same map data the tiles are drawn from, and kept on disk for a month " + "afterwards, since a runway does not move.", + flags=("--airports",), off_flags=("--no-airports",), + guidance="Turn it off for a picture with nothing but the aircraft on " + "it, or where there is no network and nothing cached."), + O("rings", "Range rings on the pictures", "The map", "bool", + "faint discs at a quarter, a half and three quarters of the radius", + "Concentric on the receiver and translucent, so that they stack: the " + "ground inside the innermost is lifted three times, the next twice, " + "the outer once. What that gives is a sense of how far away a thing " + "is without measuring anything -- an aircraft two shades in is about " + "halfway to the edge of what this receiver hears. Each is labelled " + "with its distance. They need a receiver position and a radius, and " + "are not drawn without both.", + flags=("--rings",), off_flags=("--no-rings",), + guidance="Turn it off for a picture with nothing on it but the " + "aircraft and the ground."), + O("window_rings", "Range rings in the window", "The map", "bool", + "the same discs on the realtime display", + "The same rings as the pictures get, on the window instead. They are " + "separate settings because the two are looked at differently: a " + "picture is studied and a window is glanced at, and the rings help " + "one more than the other depending on which you are doing.", + flags=("--window-rings",), off_flags=("--no-window-rings",), + guidance="Turn it off if the window is busy enough already."), + O("labels", "Label the aircraft", "Labels", "bool", "write the callsign, height and speed beside each aircraft", "Height is the flight level -- hundreds of feet -- the way it is said " "on the radio. Turning labels off leaves the shapes of the traffic, " "which is worth seeing on a busy evening.", flags=("--labels",), off_flags=("--no-labels",)), + O("speed_unit", "Speed in", "Labels", "choice", + "what to show speeds and distances in", + "Aircraft broadcast knots and the log keeps knots, because that is " + "what the standard sends; this is the unit they are shown in. It " + "changes the heading of the live display, the speeds written beside " + "each aircraft on the map and in the report, and the distance unit " + "that goes with them -- nautical miles with knots, statute miles with " + "miles an hour, kilometres with km/h, so that one picture never " + "carries two different miles.", + choices=("knots", "mph", "kph"), + guidance="knots is what aviation uses and what the aircraft actually " + "said. mph or kph if that is what means something to you."), ) -OPTION_GROUPS = ("Listening", "Drawing") +OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map", "Labels") def in_group(group: str) -> list[Setting]: diff --git a/bandsaunter/flightmap.py b/bandsaunter/flightmap.py index db13f97..56faa75 100644 --- a/bandsaunter/flightmap.py +++ b/bandsaunter/flightmap.py @@ -639,11 +639,13 @@ def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS): palette means the animation's colour table stays the same table from one frame to the next, which is the whole basis of the frame differencing. """ - # The theme has a say as well as the setting. A screen made of lines - # wants the ground well out of the way: a tinted photograph of a county - # behind the vectors is the one thing that stops a vector display - # looking like one. - part = max(0.05, min(1.0, float(brightness) * THEME.ground_part)) + # The theme shapes how the setting is felt, without capping it. A + # screen made of lines wants the ground well out of the way, so those + # themes bend the middle of the range down hard -- but the top of the + # setting is still a full-brightness map on every one of them, which a + # plain multiplier could never give. + part = max(0.05, min(1.0, float(brightness))) ** THEME.ground_gamma + part = max(0.02, part) top = max(1, int(round((GROUND_SHADES - 1) * part))) return np.clip((np.asarray(levels, dtype=np.float64) * top / (GROUND_SHADES - 1)).round(), diff --git a/bandsaunter/themes.py b/bandsaunter/themes.py index fba9639..6cea4ff 100644 --- a/bandsaunter/themes.py +++ b/bandsaunter/themes.py @@ -63,11 +63,19 @@ class Theme: # is as a fraction of the line itself. Zero for no glow at all. glow: int = 0 glow_part: float = 0.34 - # How much of the asked-for brightness the map underneath actually gets. - # A screen made of lines wants the ground well out of the way: a tinted - # photograph of a county behind the vectors is the one thing that stops - # a vector display looking like one. - ground_part: float = 1.0 + # How the map-brightness setting is felt on this theme. A screen made + # of lines wants the ground well out of the way -- a tinted photograph + # of a county behind the vectors is the one thing that stops a vector + # display looking like one -- so the vector themes bend the setting down + # hard in the middle of its range. + # + # A curve rather than a ceiling, which is what it used to be. Multiplied + # by a quarter, the setting could not reach a visible map at all on those + # themes: turned the whole way up it still came out at a tenth the + # brightness the default theme gives, which is to say invisible. Raised + # to a power instead, the top of the setting is a full-brightness map on + # every theme and only the middle is quiet. + ground_gamma: float = 1.0 # Whether flags are drawn as flags. A flag is half a dozen colours, and # on a single-phosphor screen there are not half a dozen colours to draw # it in -- so those themes name the country in two letters instead, @@ -124,7 +132,7 @@ DIGITAL = Theme( ground_high=(0, 132, 190), glow=2, glow_part=0.50, - ground_part=0.26, + ground_gamma=3.8, flags=False, height_is_brightness=True, aliases=("blue", "wargames", "norad"), @@ -152,7 +160,7 @@ PHOSPHOR = Theme( ground_high=(0, 148, 62), glow=2, glow_part=0.50, - ground_part=0.26, + ground_gamma=3.8, flags=False, height_is_brightness=True, aliases=("green", "p1"), @@ -179,7 +187,7 @@ AMBER = Theme( ground_high=(170, 106, 16), glow=2, glow_part=0.50, - ground_part=0.26, + ground_gamma=3.8, flags=False, height_is_brightness=True, aliases=("orange",), @@ -205,7 +213,7 @@ RED = Theme( ground_high=(168, 30, 24), glow=2, glow_part=0.50, - ground_part=0.26, + ground_gamma=3.8, flags=False, height_is_brightness=True, aliases=("crimson",), diff --git a/bandsaunter/tui.py b/bandsaunter/tui.py index 173a21a..12de9e0 100644 --- a/bandsaunter/tui.py +++ b/bandsaunter/tui.py @@ -722,9 +722,7 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None: _rule(console, "aircraft (ADS-B)") console.print(Panel(Text.from_markup(_AIRCRAFT_INTRO), border_style="blue", padding=(0, 1))) - _options_table(console, options, "Listening") - console.print() - _options_table(console, options, "Drawing") + _option_groups(console, options) logs = air.logs_in(cfg.output_dir) kept = "no logs yet" if not logs else \ f"{len(logs)} log{'s' if len(logs) != 1 else ''}" @@ -738,8 +736,8 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None: f"[/grey62]\n" f" [cyan]m[/cyan] Draw a map from a log [grey62]{kept} in " f"{cfg.output_dir}[/grey62]\n" - f" [cyan]N[/cyan] change option N " - f"[grey62]and [cyan]?N[/cyan] says what option N does[/grey62]\n" + f" [cyan]N[/cyan] open group N " + f"[grey62]or type part of an option's name to find it[/grey62]\n" f" [cyan]s[/cyan] Save these as default " f"[grey62]kept in {air.options_path()}[/grey62]\n" f" [cyan]d[/cyan] Reset them\n" @@ -765,11 +763,124 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None: if _confirm(" reset every aircraft option"): options = air.AircraftOptions() console.print(" [green]reset[/green]") + elif answer.isdigit() and 1 <= int(answer) <= len(air.OPTION_GROUPS): + _option_group_menu(console, options, + air.OPTION_GROUPS[int(answer) - 1]) elif answer.lstrip("?").strip().isdigit(): _edit_option(console, options, answer) + elif answer: + found = _find_options(answer) + if not found: + console.print(f" [yellow]nothing matches {answer!r} \u2014 " + f"enter a group number, or p, r, m, s, d or b" + f"[/yellow]") + elif len(found) == 1: + _edit_option(console, options, + str(air.OPTIONS.index(found[0]) + 1)) + else: + _option_list(console, options, found, f"matching {answer!r}") + _pick_option(console, options) + + +def _option_groups(console: Console, options) -> None: + """The groups, and how many of each has been changed from the default. + + A list of six lines rather than a table of thirty-three: the options + are all still there, and this is the way in to them. + """ + from . import aircraft as air + + default = air.AircraftOptions() + t = Table(box=None, header_style="bold", pad_edge=False, + title="[bold]options[/bold]", title_justify="left") + t.add_column("#", style="grey62", width=3, justify="right") + t.add_column("group", width=20) + t.add_column("", width=16, style="grey62") + t.add_column("what is in it", style="grey62", overflow="fold") + for i, group in enumerate(air.OPTION_GROUPS, 1): + items = air.in_group(group) + changed = sum(1 for o in items + if getattr(options, o.key) != getattr(default, o.key)) + count = f"{len(items)} option{'s' if len(items) != 1 else ''}" + if changed: + count += f", {changed} changed" + t.add_row(str(i), group.lower(), + Text(count, style="bold cyan" if changed else "grey62"), + ", ".join(o.label.lower() for o in items)) + console.print(t) + + +def _find_options(text: str) -> list: + """Every option this could mean, nearest match first. + + An exact name wins outright. Typing "seconds" should reach the setting + called seconds, not that one and every other whose description happens + to mention the word -- so a name that matches exactly is the answer, and + the wider search is only what happens when nothing does. + """ + from . import aircraft as air + + wanted = text.strip().lower() + if not wanted: + return [] + exact = [o for o in air.OPTIONS + if wanted in (o.key.lower(), o.label.lower())] + if exact: + return exact + return [o for o in air.OPTIONS + if wanted in o.key.lower() or wanted in o.label.lower() + or wanted in o.help.lower()] + + +def _option_list(console: Console, options, items, title: str) -> None: + """One table of whichever options were asked for.""" + from . import aircraft as air + + default = air.AircraftOptions() + t = Table(box=None, header_style="bold", pad_edge=False, + title=f"[bold]{title}[/bold]", title_justify="left") + t.add_column("#", style="grey62", width=3, justify="right") + t.add_column("option", width=20) + t.add_column("value", width=16) + t.add_column("what it does", style="grey62", overflow="fold") + for o in items: + value = air.format_option(o, getattr(options, o.key)) + changed = getattr(options, o.key) != getattr(default, o.key) + t.add_row(str(air.OPTIONS.index(o) + 1), o.label + (" *" if changed else ""), + Text(value, style="bold cyan" if changed else "white"), + o.help) + console.print(t) + + +def _pick_option(console: Console, options) -> None: + """Ask which of the options just listed to change, and change it.""" + console.print("[grey62]Enter an option number to change it, " + "[cyan]?N[/cyan] for what it does, or blank to go back." + "[/grey62]") + answer = _ask(console, " option").strip().lower() + if answer and answer.lstrip("?").strip().isdigit(): + _edit_option(console, options, answer) + + +def _option_group_menu(console: Console, options, group: str) -> None: + """One group of options, on a screen of its own.""" + from . import aircraft as air + + while True: + _rule(console, group.lower()) + items = air.in_group(group) + _option_list(console, options, items, group.lower()) + console.print("\n[grey62]Enter an option number to change it, " + "[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] " + "to go back.[/grey62]") + answer = _ask(console, " option", "b").strip().lower() + if not answer or answer in _BACK: + return + if answer.lstrip("?").strip().isdigit(): + _edit_option(console, options, answer) else: - console.print(" [yellow]enter a number from the lists, or " - "p, r, m, s, d or b[/yellow]") + console.print(" [yellow]enter a number from the list, " + "or b[/yellow]") def _edit_option(console: Console, options, answer: str) -> None: diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 81b86e4..69df94f 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-man.py -- do not edit by hand. -.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_01" "User Commands" +.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_02" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -1639,6 +1639,18 @@ every theme, that being the one mark on the picture whose meaning must not change with the colours as well as the red furthest from every altitude colour. It is drawn only where the receiver was actually told where it is: a middle worked out from whatever flew past is not a place anybody is standing. +.SS The options menu +The aircraft options are in six groups \[em] receiver, listening, aircraft, +animation, the map, and labels \[em] rather than in one list, thirty-three of +them on a screen being a wall rather than a menu. A number opens a group; +inside it a number changes an option and +.BI ? N +explains one at length. The numbers are the option's place in the whole list, +so the same number means the same option wherever it is typed. Typing a name +instead goes straight to that option, and part of a name lists everything it +could mean; a name that matches exactly wins outright, so "speed" reaches the +setting called speed rather than that one and every other whose description +mentions the word. .SS Range rings .B \-\-rings puts faint discs at a quarter, a half and three quarters of the radius, diff --git a/packaging/make-man.py b/packaging/make-man.py index e7b69bf..a8728e8 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -1032,6 +1032,18 @@ every theme, that being the one mark on the picture whose meaning must not change with the colours as well as the red furthest from every altitude colour. It is drawn only where the receiver was actually told where it is: a middle worked out from whatever flew past is not a place anybody is standing. +.SS The options menu +The aircraft options are in six groups \[em] receiver, listening, aircraft, +animation, the map, and labels \[em] rather than in one list, thirty-three of +them on a screen being a wall rather than a menu. A number opens a group; +inside it a number changes an option and +.BI ? N +explains one at length. The numbers are the option's place in the whole list, +so the same number means the same option wherever it is typed. Typing a name +instead goes straight to that option, and part of a name lists everything it +could mean; a name that matches exactly wins outright, so "speed" reaches the +setting called speed rather than that one and every other whose description +mentions the word. .SS Range rings .B \-\-rings puts faint discs at a quarter, a half and three quarters of the radius, diff --git a/tests/test_aircraft_menu.py b/tests/test_aircraft_menu.py index 1436f09..6c87981 100644 --- a/tests/test_aircraft_menu.py +++ b/tests/test_aircraft_menu.py @@ -54,6 +54,17 @@ def number(key: str) -> str: return str(air.OPTIONS.index(air.by_key(key)) + 1) +def reach(key: str) -> str: + """What to type at the top of the aircraft menu to edit one option. + + Its name: the options live in groups now, so a bare number there opens + a group. A name that matches one exactly goes straight to it, which is + how somebody who knows what they are looking for gets at it without + hunting through the groups first. + """ + return key + + def run(monkeypatch, console, answers, cfg): drive(monkeypatch, answers) try: @@ -207,12 +218,12 @@ def test_a_broken_options_file_falls_back_to_the_defaults(settings_dir): def _listen_and_draw(monkeypatch, console, tmp_path, picture="png"): cfg = ScanConfig(output_dir=str(tmp_path)) run(monkeypatch, console, - [number("simulate"), "yes", # invent a sky - number("seconds"), "3", # listen for three seconds - number("picture"), picture, # what to draw - number("lookup"), "no", # no lookups: no network in a test - number("basemap"), "no", # nor a tile server - number("airports"), "no", # nor the map data + [reach("simulate"), "yes", # invent a sky + reach("seconds"), "3", # listen for three seconds + reach("picture"), picture, # what to draw + reach("lookup"), "no", # no lookups: no network in a test + reach("basemap"), "no", # nor a tile server + reach("airports"), "no", # nor the map data "l", # listen now "m", "1", # draw the newest log "b"], cfg) @@ -265,10 +276,10 @@ def test_listening_can_draw_as_soon_as_it_stops(monkeypatch, console, """One key, from nothing to a picture.""" cfg = ScanConfig(output_dir=str(tmp_path)) run(monkeypatch, console, - [number("simulate"), "yes", number("seconds"), "3", - number("lookup"), "no", number("basemap"), "no", - number("airports"), "no", number("draw_after"), "yes", - number("picture"), "png", "l", "b"], cfg) + [reach("simulate"), "yes", reach("seconds"), "3", + reach("lookup"), "no", reach("basemap"), "no", + reach("airports"), "no", reach("draw_after"), "yes", + reach("picture"), "png", "l", "b"], cfg) assert list(tmp_path.glob("adsb_*.png")) @@ -430,3 +441,89 @@ def test_leaving_it_off_changes_nothing(capsys): same = air.checked(loud, air.AircraftOptions(recheck=False), [track]) assert same[0] is track assert capsys.readouterr().out.strip() == "" + + +# --------------------------------------------------------------------------- +# The options, in groups +# --------------------------------------------------------------------------- + +def test_no_group_is_long_enough_to_need_scrolling(): + """Thirty-three options on one screen is a wall. The point of the + groups is that each of them fits in front of you at once.""" + for group in air.OPTION_GROUPS: + items = air.in_group(group) + assert 1 <= len(items) <= 10, (group, len(items)) + + +def test_every_option_is_in_exactly_one_group(): + seen = [o for group in air.OPTION_GROUPS for o in air.in_group(group)] + assert len(seen) == len(air.OPTIONS) + assert {o.key for o in seen} == {o.key for o in air.OPTIONS} + + +def test_each_group_sits_together_in_the_numbering(): + """The number beside an option is its place in the whole list, so that + the same number means the same option wherever it is typed. That only + reads sensibly if a group's options are next to each other.""" + for group in air.OPTION_GROUPS: + places = [air.OPTIONS.index(o) for o in air.in_group(group)] + assert places == list(range(places[0], places[0] + len(places))), group + + +def test_a_name_typed_in_full_goes_straight_to_that_option(): + """Typing "seconds" should reach the setting called seconds, not that + one and every other whose description mentions the word.""" + for key in ("seconds", "picture", "simulate", "speed", "width", "rings"): + found = tui._find_options(key) + assert [o.key for o in found] == [key], (key, [o.key for o in found]) + + +def test_a_part_of_a_name_finds_everything_it_could_mean(): + found = [o.key for o in tui._find_options("ring")] + assert "rings" in found and "window_rings" in found + + +def test_a_name_nobody_has_finds_nothing(): + assert tui._find_options("zzz") == [] + assert tui._find_options("") == [] + + +def test_opening_a_group_shows_its_options_and_nothing_else(monkeypatch, + console, capsys, + settings_dir): + from rich.console import Console + + loud = Console(width=100, force_terminal=False, no_color=True) + where = air.OPTION_GROUPS.index("The map") + 1 + run(monkeypatch, loud, [str(where), "b", "b"], ScanConfig()) + printed = capsys.readouterr().out + for option in air.in_group("The map"): + assert option.label.split()[0] in printed, option.key + # An option from another group is not on that screen. + after = printed.split("the map", 2)[-1] + assert "Tuner gain" not in after and "Listen for" not in after + + +def test_typing_an_option_name_at_the_top_opens_that_option(monkeypatch, + console, + settings_dir): + """The way in for somebody who knows what they are looking for and does + not want to hunt through the groups for it.""" + held = air.AircraftOptions() + monkeypatch.setattr(air, "load_options", lambda *a, **kw: held) + run(monkeypatch, console, ["map brightness", "45", "b"], ScanConfig()) + assert held.map_brightness == 45 + + +def test_a_group_number_at_the_top_does_not_edit_the_option_of_that_number( + monkeypatch, console, settings_dir): + """A bare number at the top of the menu opens a group. It used to edit + the option with that number, and the two would otherwise disagree.""" + held = air.AircraftOptions() + was = held.seconds + monkeypatch.setattr(air, "load_options", lambda *a, **kw: held) + # Option 1 is the receiver; group 1 is the receiver group. Typing 1 + # and then going back must leave everything alone. + run(monkeypatch, console, ["1", "b", "b"], ScanConfig()) + assert held.seconds == was + assert held.device == air.AircraftOptions().device diff --git a/tests/test_themes.py b/tests/test_themes.py index af7bee1..62a0e7a 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -244,8 +244,9 @@ def test_a_phosphor_theme_names_the_country_instead_of_drawing_its_flag(): def test_a_vector_theme_pushes_the_map_underneath_well_back(): - """A tinted photograph of a county behind the vectors is the one thing - that stops a vector display looking like one.""" + """In the middle of the range, where the setting usually sits: a tinted + photograph of a county behind the vectors is the one thing that stops a + vector display looking like one.""" levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8) fm.set_theme("night") plain = int(fm.dim_ground(levels, 0.7).max()) @@ -254,11 +255,27 @@ def test_a_vector_theme_pushes_the_map_underneath_well_back(): assert quiet < plain * 0.6, f"{quiet} is not much darker than {plain}" -def test_the_brightness_setting_still_does_something_on_a_vector_theme(): +def test_turning_the_brightness_the_whole_way_up_works_on_every_theme(): + """A curve, not a ceiling. Multiplied by a quarter instead, the setting + could not reach a visible map at all on a vector theme: turned the whole + way up it still came out at a tenth of what the default theme gives, + which is to say invisible.""" + levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8) + for name in themes.THEMES: + fm.set_theme(name) + assert int(fm.dim_ground(levels, 1.0).max()) == fm.GROUND_SHADES - 1, \ + f"{name} cannot reach a full-brightness map" + + +def test_the_brightness_setting_climbs_the_whole_way_on_a_vector_theme(): levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8) fm.set_theme("phosphor") - assert int(fm.dim_ground(levels, 1.0).max()) > \ - int(fm.dim_ground(levels, 0.3).max()) + steps = [int(fm.dim_ground(levels, b).max()) + for b in (0.1, 0.3, 0.5, 0.7, 0.85, 1.0)] + assert steps == sorted(steps), steps + assert steps[-1] > steps[0] * 8, steps + # And the middle of the range is still quiet, which is the look. + assert steps[3] < steps[-1] * 0.4, steps # ---------------------------------------------------------------------------