From ca2d162d39b5b547ff193bd9fe5771423264f0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Izaguirre?= Date: Mon, 10 Aug 2026 18:03:15 +0200 Subject: [PATCH] Show the drives a file can be dropped on A file dropped on the window went to drive 1 or drive 2 depending on the half it landed on. There was no way to know beforehand which drive was which or what each one had inserted, and no sign afterwards of where the file went. The machines with two Disk II cards have four drives and only the first two could be reached. F8 now shows a screen with one area per removable media drive, each with the name of its drive and the image it has inserted, or EMPTY, and the same screen appears for a moment after a drop with the drive that got the file marked. It is a text screen of 80 columns rendered the same way the help is, so SnapshotMessageGenerator takes the number of columns now. The drives register with a label and tell the media they have, so the frontends ask the machine with GetRemovableMediaDrives instead of assuming two. The name of the media is written by the emulation goroutine and read by the frontends, hence the atomic access. The new frontend/shared package holds what the frontends have in common and the emulator library has no place for: the composition of the screen and the mapping from a position to a drive. a2sdl reads the position of the pointer on the desktop when the file is dropped. The mouse motion events do not arrive while another application drags a file, so the last known position was stale and the file could land on the wrong drive. a2sdl3 shows the areas while the file is being dragged over the window, which is something only SDL3 reports. a2ebiten had no drag and drop at all, although the help it shows said otherwise. Ebitengine hides the paths of the files dropped, but it opens the real files and the handle tells the path back, so the images are loaded like on every other frontend, compressed files included. --- AGENTS.md | 1 + README.md | 2 +- apple2.go | 24 ++- cardDisk2.go | 18 ++- cardDisk2Sequencer.go | 9 +- cardDisk2SequencerDrive.go | 6 + command.go | 4 +- doc/frontend_ebiten.md | 35 +++-- doc/frontend_sdl2.md | 20 ++- doc/frontend_sdl3.md | 7 +- frontend/a2ebiten/ebitenDropTargets.go | 134 ++++++++++++++++ frontend/a2ebiten/ebitenKeyboard.go | 19 ++- frontend/a2ebiten/main.go | 27 ++-- frontend/a2sdl/main.go | 23 +-- frontend/a2sdl/sdlDropTargets.go | 87 ++++++++++ frontend/a2sdl/sdlKeyboard.go | 17 +- frontend/a2sdl3/main.go | 32 +++- frontend/a2sdl3/sdl3DropTargets.go | 100 ++++++++++++ frontend/a2sdl3/sdl3Keyboard.go | 17 +- frontend/shared/dropTargets.go | 210 +++++++++++++++++++++++++ frontend/shared/dropTargets_test.go | 126 +++++++++++++++ frontend/shared/textCanvas.go | 72 +++++++++ removableMedia.go | 62 ++++++++ screen/snapshotsDebug.go | 19 ++- 24 files changed, 981 insertions(+), 90 deletions(-) create mode 100644 frontend/a2ebiten/ebitenDropTargets.go create mode 100644 frontend/a2sdl/sdlDropTargets.go create mode 100644 frontend/a2sdl3/sdl3DropTargets.go create mode 100644 frontend/shared/dropTargets.go create mode 100644 frontend/shared/dropTargets_test.go create mode 100644 frontend/shared/textCanvas.go create mode 100644 removableMedia.go diff --git a/AGENTS.md b/AGENTS.md index 91c3e3f70..30be0b280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ The project includes E2E boot tests (`e2e_boot_test.go`, `e2e_woz_test.go`) that - Main emulator code is in the root package `izapple2` - Subpackages include: `storage`, `screen`, `fujinet`, `component` - Frontend implementations are in `frontend/` directory +- `frontend/shared` has the code more than one frontend needs and the emulator library has no place for, like the drop targets screen ### Naming Conventions diff --git a/README.md b/README.md index 3aab9cf6d..654b752bb 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Each one has a page with how to build it, how to use it and what it can not do: - [**a2sdl3**](doc/frontend_sdl3.md): the same, on SDL3. It needs neither cgo, nor a C compiler, nor SDL developer files, and it cross compiles to every platform from any of them. Experimental, not in the releases. - [**console**](doc/frontend_console.md): text mode right on the terminal with ANSI escape codes, without the SDL2 dependency. Input goes in a line at a time. - [**a2libretro**](doc/frontend_libretro.md): a libretro core, to run inside RetroArch, Lakka, Batocera, RetroPie and the rest. On a Mac, [doc/frontend_libretro_macos.md](doc/frontend_libretro_macos.md) walks through the whole thing from installing RetroArch. -- [**a2ebiten**](doc/frontend_ebiten.md): a window with [Ebitengine](https://ebitengine.org/). The same keys as a2sdl, no disks to drop and no joysticks. It is the desktop half of the WebAssembly frontend. +- [**a2ebiten**](doc/frontend_ebiten.md): a window with [Ebitengine](https://ebitengine.org/). The same keys as a2sdl, the same disks dropped on the window and no joysticks. It is the desktop half of the WebAssembly frontend. - [**a2wasm**](doc/frontend_wasm.md): the emulator in the browser, compiled to WebAssembly with a React interface around it. - [**a2fyne**](doc/frontend_fyne.md): a window with [Fyne](https://fyne.io/), a toolbar and a panel listing the cards in the slots. No sound. Unfinished. - [**headless**](doc/frontend_headless.md): no window and no screen, a command prompt to drive the machine and take snapshots. For scripting and for tests. diff --git a/apple2.go b/apple2.go index 471e4082e..991f54c26 100644 --- a/apple2.go +++ b/apple2.go @@ -11,12 +11,13 @@ import ( // Apple2 represents all the components and state of the emulated machine type Apple2 struct { - Name string - cpu *iz6502.State - mmu *memoryManager - io *ioC0Page - video screen.VideoSource - cg *CharacterGenerator + Name string + cpu *iz6502.State + mmu *memoryManager + io *ioC0Page + video screen.VideoSource + cg *CharacterGenerator + cards [8]Card tracers []executionTracer tickerCards []cardTicker @@ -42,7 +43,7 @@ type Apple2 struct { paused atomic.Bool cpuTrace bool forceCaps bool - removableMediaDrives []drive + removableMediaDrives []removableMediaDrive currentFreqMHz float64 } @@ -78,7 +79,6 @@ func (a *Apple2) SetKeyboardProvider(kb KeyboardProvider) { a.io.setKeyboardProvider(kb) } - // SetJoysticksProvider attaches an external joysticks provider func (a *Apple2) SetJoysticksProvider(j JoysticksProvider) { a.io.setJoysticksProvider(j) @@ -157,10 +157,6 @@ func (a *Apple2) IsFastModeRequested() bool { return atomic.LoadInt32(&a.fastRequestsCounter) > 0 } -func (a *Apple2) registerRemovableMediaDrive(d drive) { - a.removableMediaDrives = append(a.removableMediaDrives, d) -} - // InsertDiskette inserts a diskette into a drive (for WASM/in-memory loading) func (a *Apple2) InsertDiskette(unit int, diskette storage.Diskette, name string) error { if unit < 0 || unit >= len(a.removableMediaDrives) { @@ -168,9 +164,9 @@ func (a *Apple2) InsertDiskette(unit int, diskette storage.Diskette, name string } // Type assert to access the diskette field - switch d := a.removableMediaDrives[unit].(type) { + switch d := a.removableMediaDrives[unit].drive.(type) { case *cardDisk2Drive: - d.name = name + d.name.set(name) d.diskette = diskette return nil default: diff --git a/cardDisk2.go b/cardDisk2.go index 3815b08b3..85f3162f0 100644 --- a/cardDisk2.go +++ b/cardDisk2.go @@ -41,6 +41,7 @@ type CardDisk2 struct { type drive interface { insertDiskette(path string) error + getMediaName() string } type cardDisk2Drive struct { @@ -49,7 +50,7 @@ type cardDisk2Drive struct { // card and is kept here for the diskettes inserted later on. saveDirectory string - name string + name mediaName diskette storage.Diskette phases uint8 // q3, q2, q1 and q0 with q0 on the LSB. Magnets that are active on the stepper motor trackStep int // Stepmotor for tracks position. 4 steps per track @@ -124,10 +125,10 @@ func (c *CardDisk2) GetInfo() map[string]string { } info["power"] = strconv.FormatBool(c.power) - info["D1 name"] = c.drive[0].name + info["D1 name"] = c.drive[0].name.get() info["D1 track"] = strconv.FormatFloat(float64(c.drive[0].trackStep)/4, 'f', 2, 64) - info["D2 name"] = c.drive[1].name + info["D2 name"] = c.drive[1].name.get() info["D2 track"] = strconv.FormatFloat(float64(c.drive[1].trackStep)/4, 'f', 2, 64) return info } @@ -147,8 +148,9 @@ func (c *CardDisk2) setTrackTracer(tt trackTracer) { } func (c *CardDisk2) assign(a *Apple2, slot int) { - a.registerRemovableMediaDrive(&c.drive[0]) - a.registerRemovableMediaDrive(&c.drive[1]) + for i := range c.drive { + a.registerRemovableMediaDrive(&c.drive[i], fmt.Sprintf("S%vD%v", slot, i+1)) + } // Q1, Q2, Q3 and Q4 phase control soft switches, for i := range uint8(4) { @@ -307,7 +309,11 @@ func (d *cardDisk2Drive) insertDiskette(name string) error { return err } - d.name = name + d.name.set(name) d.diskette = diskette return nil } + +func (d *cardDisk2Drive) getMediaName() string { + return d.name.get() +} diff --git a/cardDisk2Sequencer.go b/cardDisk2Sequencer.go index 3cb9fae01..459b0d062 100644 --- a/cardDisk2Sequencer.go +++ b/cardDisk2Sequencer.go @@ -1,6 +1,8 @@ package izapple2 import ( + "fmt" + "github.com/ivanizag/izapple2/component" ) @@ -46,7 +48,7 @@ type cardDisk2Shared interface { const ( disk2MotorOffDelay = uint64(2 * 1000 * 1000) // 2 Mhz cycles. Total 1 second. - disk2PulseCycles = uint8(8) // 8 cycles = 4ms * 2Mhz + disk2PulseCycles = uint8(8) // 8 cycles = 4ms * 2Mhz /* We skip register calculations for long periods with the motor @@ -135,8 +137,9 @@ func (c *CardDisk2Sequencer) setTrackTracer(tt trackTracer) { } func (c *CardDisk2Sequencer) assign(a *Apple2, slot int) { - a.registerRemovableMediaDrive(&c.drive[0]) - a.registerRemovableMediaDrive(&c.drive[1]) + for i := range c.drive { + a.registerRemovableMediaDrive(&c.drive[i], fmt.Sprintf("S%vD%v", slot, i+1)) + } c.addCardSoftSwitches(func(address uint8, data uint8, _ bool) uint8 { /* diff --git a/cardDisk2SequencerDrive.go b/cardDisk2SequencerDrive.go index ae69adccf..a755158e0 100644 --- a/cardDisk2SequencerDrive.go +++ b/cardDisk2SequencerDrive.go @@ -9,6 +9,7 @@ import ( ) type cardDisk2SequencerDrive struct { + name mediaName data *storage.FileWoz enabled bool writeProtected bool @@ -35,12 +36,17 @@ func (d *cardDisk2SequencerDrive) insertDiskette(filename string) error { return errors.New("only 5.25 disks are supported") } + d.name.set(filename) d.data = f d.writeProtected = !writeable return nil } +func (d *cardDisk2SequencerDrive) getMediaName() string { + return d.name.get() +} + func (d *cardDisk2SequencerDrive) enable(enabled bool) { d.enabled = enabled } diff --git a/command.go b/command.go index 129082b7e..3aaa5dc98 100644 --- a/command.go +++ b/command.go @@ -100,8 +100,8 @@ func (a *Apple2) executeCommand(command command) { } func (a *Apple2) changeDisk(unit int, path string) error { - if unit < len(a.removableMediaDrives) { - return a.removableMediaDrives[unit].insertDiskette(path) + if unit >= 0 && unit < len(a.removableMediaDrives) { + return a.removableMediaDrives[unit].drive.insertDiskette(path) } return fmt.Errorf("unit %v not defined", unit) } diff --git a/doc/frontend_ebiten.md b/doc/frontend_ebiten.md index 43f295443..34fd00f25 100644 --- a/doc/frontend_ebiten.md +++ b/doc/frontend_ebiten.md @@ -1,9 +1,9 @@ # The Ebitengine frontend `a2ebiten` opens a window with [Ebitengine](https://ebitengine.org/). It has -the screen with all its modes, the sound and the same function keys as -[a2sdl](frontend_sdl2.md), and it is missing the things around them: no -diskettes dropped on the window, no joysticks and no mouse. +the screen with all its modes, the sound, the diskettes dropped on the window +and the same function keys as [a2sdl](frontend_sdl2.md), and it is missing the +things around them: no joysticks and no mouse. ## Building @@ -24,9 +24,8 @@ go build . casa@servidor:~$ ./a2ebiten ``` -The [command line options](command_line.md) are the same as everywhere else, -and the diskettes have to go in the command line: this frontend has no way to -insert one afterwards. Press F1 for the help. +The [command line options](command_line.md) are the same as everywhere else. +Press F1 for the help. ## Keys @@ -37,12 +36,30 @@ The same as [a2sdl](frontend_sdl2.md#keys), with two differences: terminal. - There is no paste from the clipboard. -The help screen that F1 shows is the one of a2sdl and mentions dropping a file -on the window. That does not work here. +## Diskettes + +Drop a file on the window to insert it. The window is divided in as many +vertical areas as removable media drives the machine has, and the file goes to +the drive of the area it is dropped on. F8 shows a screen with the areas, each +one with the name of its drive and the image it has inserted, or `EMPTY`, and +it is shown again for a moment after a drop with the drive that got the file +marked. + +Ebitengine hands over the files dropped as a file system that hides their +paths, but it opens the real files and the handle of a file tells its path +back. The image is loaded from there like on any other frontend, compressed +files included, and whatever the emulated software writes goes back to the file +or to the `-saveDir` directory. In the browser the files dropped have no path +and cannot be loaded. + +Ebitengine does not report where a file was dropped either, and the pointer +position is not updated while another application drags a file over the window, +so the area used is the one the pointer was last seen on. Check with F8 before +dragging, or look at the areas shown after the drop to see where the file +landed. ## What is missing -- **Diskettes dropped on the window.** They go in the command line. - **Joysticks and paddles**, and the mouse as a joystick. - **The mouse**, so the models that use it, like `desktop`, are not much use. - **Pasting** from the clipboard. diff --git a/doc/frontend_sdl2.md b/doc/frontend_sdl2.md index ada477703..a2faf7397 100644 --- a/doc/frontend_sdl2.md +++ b/doc/frontend_sdl2.md @@ -92,6 +92,7 @@ Escape mapped to what the Apple II understands. The rest: | Ctrl-F5 | Print the current speed on the terminal | | F6 | Next screen mode: NTSC colour, plain, green, with or without scan lines | | F7 | Show or hide the four panels with the actual screen, page 1, page 2 and the extra info of the video mode | +| F8 | Show or hide the areas where a diskette can be dropped | | F9 | Dump the state of the machine on the terminal | | F10 | Next character set | | Ctrl-F10 | Show or hide the character map | @@ -110,9 +111,22 @@ being displayed instead. ## Diskettes -Drop a file on the window to insert it: on the left half it goes to drive 1, on -the right half to drive 2. It works with everything that goes on the command -line, including compressed images. +Drop a file on the window to insert it. The window is divided in as many +vertical areas as removable media drives the machine has, and the file goes to +the drive of the area it is dropped on. It works with everything that goes on +the command line, including compressed images. + +F8 shows a screen with the areas, each one with the name of its drive and the +image it has inserted, or `EMPTY`, and the one under the pointer marked. It +takes over the picture the same way the help does, on 80 columns. The same +screen is shown for a moment after a drop, with the drive that got the file +marked. + +SDL2 does not report a file being dragged over the window, so the areas cannot +be shown while the file moves; the position of the pointer is read when the +file is dropped. The [SDL3 frontend](frontend_sdl3.md) does show them during +the drag. The areas are the same on every frontend, they are built in +`frontend/shared`. Whatever the emulated software writes goes back to the file it came from. To keep the images untouched use `-saveDir`, which puts the writes in an overlay diff --git a/doc/frontend_sdl3.md b/doc/frontend_sdl3.md index 6fb351d82..b3f8c8cb5 100644 --- a/doc/frontend_sdl3.md +++ b/doc/frontend_sdl3.md @@ -54,9 +54,10 @@ and the Closed-Apple would never be pressed if it were looked up by keycode. Nothing that you use, and a few things underneath: -- SDL3 reports where a file was dropped, so the drive it goes to is the one - under the pointer. In SDL2 it is worked out from the last known mouse - position. +- SDL3 reports a file being dragged over the window, so the screen with the + areas of the drives it can be dropped on is shown while it moves, with the + one under the pointer marked. SDL2 only knows about the file once it is + dropped, and has to show the areas with F8 or after the drop. - Losing the window focus releases the joystick keys, so an Open-Apple held while switching windows does not stay pressed. - Ctrl-C on the terminal quits the same way as closing the window, releasing diff --git a/frontend/a2ebiten/ebitenDropTargets.go b/frontend/a2ebiten/ebitenDropTargets.go new file mode 100644 index 000000000..8021e6042 --- /dev/null +++ b/frontend/a2ebiten/ebitenDropTargets.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "image" + "io/fs" + "os" + + "github.com/ivanizag/izapple2" + "github.com/ivanizag/izapple2/frontend/shared" + + "github.com/hajimehoshi/ebiten/v2" +) + +/* +The window is divided in as many vertical areas as removable media drives, to +show where a file can be dropped and what each drive has inserted. + +Ebiten reports the files dropped but not where they were dropped, and the +cursor position is not updated while another application drags a file over the +window. The area used is the one the pointer was last seen on, the areas are +shown with F8 to know them beforehand, and they are shown again after a drop to +tell where the file went. + +Ebitengine hands the files dropped as a file system that hides their paths, but +it opens the real files, so the handle of a file tells its path back and the +diskette is loaded the same way as on the other frontends. Only the files with +a path can be loaded, which leaves out the browser. +*/ + +type ebitenDropTargets struct { + a *izapple2.Apple2 + targets *shared.DropTargets +} + +func newEbitenDropTargets(a *izapple2.Apple2) *ebitenDropTargets { + var d ebitenDropTargets + d.a = a + d.targets = shared.NewDropTargets(a) + return &d +} + +// update loads the files dropped on the window, if any +func (d *ebitenDropTargets) update() { + files := ebiten.DroppedFiles() + if files == nil { + return + } + + entries, err := fs.ReadDir(files, ".") + if err != nil { + fmt.Printf("Could not read the files dropped: %v\n", err) + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // Only the first file dropped is loaded, on the drive it was dropped on + if d.load(files, entry.Name()) { + return + } + } +} + +// load inserts a file dropped on the window. It returns false to try with the +// next file dropped, if there is one. +func (d *ebitenDropTargets) load(files fs.FS, name string) bool { + file, err := files.Open(name) + if err != nil { + fmt.Printf("Could not open '%v': %v\n", name, err) + return false + } + defer file.Close() + + drive := d.dropped() + if drive < 0 { + fmt.Printf("There are no drives to load '%v' on\n", name) + return true + } + + // The file dropped is a real file that Ebitengine has just opened. Its + // handle knows the path it came from, and the path is what the emulator + // wants: it takes the compressed images too, and the changes go back to + // the file or to the save directory. + realFile, ok := file.(*os.File) + if !ok { + fmt.Printf("Could not find where '%v' came from\n", name) + return false + } + + path := realFile.Name() + fmt.Printf("Loading '%s' in drive %v\n", path, drive+1) + d.a.SendLoadDisk(drive, path) + return true +} + +// dropped returns the drive that gets a file dropped now +func (d *ebitenDropTargets) dropped() int { + drive := d.pointedDrive() + if drive >= 0 { + d.targets.Dropped(drive) + } + return drive +} + +// showing returns whether the areas take over the screen, either because the +// user asked for them with F8 or because a file has just been dropped +func (d *ebitenDropTargets) showing(requested bool) bool { + return requested || d.targets.Flashing() +} + +// snapshot returns the screen with the areas, highlighting the drive that got +// the last file while the flash lasts, or the one under the pointer +func (d *ebitenDropTargets) snapshot() *image.RGBA { + selected := d.targets.FlashDrive() + if !d.targets.Flashing() { + selected = d.pointedDrive() + } + return d.targets.Snapshot(selected) +} + +// pointedDrive returns the drive the mouse pointer is on. The positions are on +// the virtual screen the game is laid out on, not on the window. +func (d *ebitenDropTargets) pointedDrive() int { + x, y := ebiten.CursorPosition() + if x < 0 || x >= virtualWidth || y < 0 || y >= virtualHeight { + return -1 + } + + return d.targets.DriveAt(x, virtualWidth) +} diff --git a/frontend/a2ebiten/ebitenKeyboard.go b/frontend/a2ebiten/ebitenKeyboard.go index eb949b762..de57b0157 100644 --- a/frontend/a2ebiten/ebitenKeyboard.go +++ b/frontend/a2ebiten/ebitenKeyboard.go @@ -14,12 +14,13 @@ type ebitenKeyboard struct { a *izapple2.Apple2 keyChannel *izapple2.KeyboardChannel - showHelp bool - showPages bool - showCharGen bool - showAltText bool - showFreq bool - screenMode int + showHelp bool + showPages bool + showCharGen bool + showAltText bool + showFreq bool + showDropTargets bool + screenMode int debug bool } @@ -133,6 +134,12 @@ func (k *ebitenKeyboard) putKey(key ebiten.Key) { k.screenMode = screen.NextScreenMode(k.screenMode) case ebiten.KeyF7: k.showPages = !k.showPages + case ebiten.KeyF8: + k.showDropTargets = !k.showDropTargets + if k.showDropTargets { + // The help is shown on top of the drop targets, get it out of the way + k.showHelp = false + } case ebiten.KeyF9: k.a.SendCommand(izapple2.CommandDumpDebugInfo) case ebiten.KeyF10: diff --git a/frontend/a2ebiten/main.go b/frontend/a2ebiten/main.go index ec26ce432..e628784f3 100644 --- a/frontend/a2ebiten/main.go +++ b/frontend/a2ebiten/main.go @@ -16,11 +16,12 @@ import ( ) type Game struct { - a *izapple2.Apple2 - image *ebiten.Image - keyboard *ebitenKeyboard - speaker *ebitenAudio - fontSource *text.GoTextFaceSource + a *izapple2.Apple2 + image *ebiten.Image + keyboard *ebitenKeyboard + speaker *ebitenAudio + dropTargets *ebitenDropTargets + fontSource *text.GoTextFaceSource paused bool title string @@ -40,6 +41,7 @@ var hudColor = color.RGBA{208, 241, 141, 255} // Yellow func (g *Game) Update() error { g.keyboard.update() g.speaker.update() + g.dropTargets.update() if g.paused != g.a.IsPaused() { if g.a.IsPaused() { @@ -54,7 +56,9 @@ func (g *Game) Update() error { var img *image.RGBA vs := g.a.GetVideoSource() if g.keyboard.showHelp { - img = a_screen.SnapshotMessageGenerator(vs, helpMessage) + img = a_screen.SnapshotMessageGenerator(vs, helpMessage, false /*is80Columns*/) + } else if g.dropTargets.showing(g.keyboard.showDropTargets) { + img = g.dropTargets.snapshot() } else if g.keyboard.showCharGen { cgPage, cgPages := g.a.GetCgPageInfo() img = a_screen.SnapshotCharacterGenerator(vs, g.keyboard.showAltText) @@ -130,9 +134,10 @@ func ebitenRun(a *izapple2.Apple2) { ebiten.SetWindowTitle(title) game := &Game{ - a: a, - keyboard: newEbitenKeyBoard(a), - speaker: newEbitenAudio(a.GetClockMhz()), + a: a, + keyboard: newEbitenKeyBoard(a), + speaker: newEbitenAudio(a.GetClockMhz()), + dropTargets: newEbitenDropTargets(a), } for _, source := range a.GetAudioSources() { source.SetAudioSink(game.speaker.mixer.NewSource()) @@ -159,6 +164,7 @@ var helpMessage = ` Ctrl-F5: Show speed F6: Next screen mode F7: Show/Hide pages + F8: Show/Hide drop targets F10: Next character set Ctrl-F10: Show/Hide character set Shift-F10: Show/Hide alternate text @@ -168,8 +174,7 @@ var helpMessage = ` Left alt or option key: Open-Apple Right alt or option key: Closed-Apple -Drop a file on the left or right -side of the window to load a disk +Drop a file on a drive area to load it Run izapple2 -h for more options https://github.com/ivanizag/izapple2 diff --git a/frontend/a2sdl/main.go b/frontend/a2sdl/main.go index fb4eec33e..954b76f08 100644 --- a/frontend/a2sdl/main.go +++ b/frontend/a2sdl/main.go @@ -70,9 +70,10 @@ func sdlRun(a *izapple2.Apple2) { m := newSDLMouse() a.SetMouseProvider(m) + d := newSDLDropTargets(a, window) + go a.Run() - var x int32 paused := false running := true for running { @@ -94,17 +95,19 @@ func sdlRun(a *izapple2.Apple2) { w, h := window.GetSize() j.putMouseMotionEvent(t, w, h) m.putMouseMotionEvent(t, w, h) - x = t.X case *sdl.MouseButtonEvent: j.putMouseButtonEvent(t) m.putMouseButtonEvent(t) case *sdl.DropEvent: switch t.Type { case sdl.DROPFILE: - w, _ := window.GetSize() - drive := int(2 * x / w) - fmt.Printf("Loading '%s' in drive %v\n", t.File, drive+1) - a.SendLoadDisk(drive, t.File) + drive := d.dropped() + if drive >= 0 { + fmt.Printf("Loading '%s' in drive %v\n", t.File, drive+1) + a.SendLoadDisk(drive, t.File) + } else { + fmt.Printf("There are no drives to load '%s' on\n", t.File) + } } } } @@ -122,7 +125,9 @@ func sdlRun(a *izapple2.Apple2) { var img *image.RGBA vs := a.GetVideoSource() if kp.showHelp { - img = screen.SnapshotMessageGenerator(vs, helpMessage) + img = screen.SnapshotMessageGenerator(vs, helpMessage, false /*is80Columns*/) + } else if d.showing(kp.showDropTargets) { + img = d.snapshot() } else if kp.showCharGen { cgPage, cgPages := a.GetCgPageInfo() img = screen.SnapshotCharacterGenerator(vs, kp.showAltText) @@ -171,6 +176,7 @@ var helpMessage = ` Ctrl-F5: Show speed F6: Next screen mode F7: Show/Hide pages + F8: Show/Hide drop targets F10: Next character set Ctrl-F10: Show/Hide character set Shift-F10: Show/Hide alternate text @@ -180,8 +186,7 @@ var helpMessage = ` Left alt or option key: Open-Apple Right alt or option key: Closed-Apple -Drop a file on the left or right -side of the window to load a disk +Drop a file on a drive area to load it Run izapple2 -h for more options https://github.com/ivanizag/izapple2 diff --git a/frontend/a2sdl/sdlDropTargets.go b/frontend/a2sdl/sdlDropTargets.go new file mode 100644 index 000000000..144bc781b --- /dev/null +++ b/frontend/a2sdl/sdlDropTargets.go @@ -0,0 +1,87 @@ +//go:build !js + +package main + +import ( + "image" + + "github.com/ivanizag/izapple2" + "github.com/ivanizag/izapple2/frontend/shared" + "github.com/veandco/go-sdl2/sdl" +) + +/* +The window is divided in as many vertical areas as removable media drives, to +show where a file can be dropped and what each drive has inserted. + +SDL2 does not tell that a file is being dragged over the window: the drop +events arrive only when the file is released, and SDL_DropEvent has no +position. The areas are shown with F8 then, and after a drop to tell where the +file went. SDL3 does report the drag, see the a2sdl3 frontend. +*/ + +type sdlDropTargets struct { + targets *shared.DropTargets + window *sdl.Window +} + +func newSDLDropTargets(a *izapple2.Apple2, window *sdl.Window) *sdlDropTargets { + var d sdlDropTargets + d.targets = shared.NewDropTargets(a) + d.window = window + return &d +} + +// dropped returns the drive that gets a file dropped now +func (d *sdlDropTargets) dropped() int { + width, _ := d.window.GetSize() + + // The mouse motion events do not arrive while another application drags a + // file, so the position of the pointer on the window is stale and the one + // on the desktop is asked for instead. Where that is not available, as on + // Wayland, it lands outside the window and the stale one is all there is. + globalX, _, _ := sdl.GetGlobalMouseState() + windowX, _ := d.window.GetPosition() + x := globalX - windowX + if x < 0 || x >= width { + x, _, _ = sdl.GetMouseState() + } + + drive := d.targets.DriveAt(int(x), int(width)) + if drive >= 0 { + d.targets.Dropped(drive) + } + return drive +} + +// showing returns whether the areas take over the screen, either because the +// user asked for them with F8 or because a file has just been dropped +func (d *sdlDropTargets) showing(requested bool) bool { + return requested || d.targets.Flashing() +} + +// snapshot returns the screen with the areas, highlighting the drive that got +// the last file while the flash lasts, or the one under the pointer +func (d *sdlDropTargets) snapshot() *image.RGBA { + selected := d.targets.FlashDrive() + if !d.targets.Flashing() { + selected = d.pointedDrive() + } + return d.targets.Snapshot(selected) +} + +// pointedDrive returns the drive the mouse pointer is on, -1 when it is +// outside the window +func (d *sdlDropTargets) pointedDrive() int { + if sdl.GetMouseFocus() != d.window { + return -1 + } + + mouseX, _, _ := sdl.GetMouseState() + width, _ := d.window.GetSize() + if mouseX < 0 || mouseX >= width { + return -1 + } + + return d.targets.DriveAt(int(mouseX), int(width)) +} diff --git a/frontend/a2sdl/sdlKeyboard.go b/frontend/a2sdl/sdlKeyboard.go index 6861838b7..633aa3f1a 100644 --- a/frontend/a2sdl/sdlKeyboard.go +++ b/frontend/a2sdl/sdlKeyboard.go @@ -15,11 +15,12 @@ type sdlKeyboard struct { a *izapple2.Apple2 keyChannel *izapple2.KeyboardChannel - showHelp bool - showPages bool - showCharGen bool - showAltText bool - screenMode int + showHelp bool + showPages bool + showCharGen bool + showAltText bool + showDropTargets bool + screenMode int } func newSDLKeyBoard(a *izapple2.Apple2) *sdlKeyboard { @@ -118,6 +119,12 @@ func (k *sdlKeyboard) putKey(keyEvent *sdl.KeyboardEvent) { k.screenMode = screen.NextScreenMode(k.screenMode) case sdl.K_F7: k.showPages = !k.showPages + case sdl.K_F8: + k.showDropTargets = !k.showDropTargets + if k.showDropTargets { + // The help is shown on top of the drop targets, get it out of the way + k.showHelp = false + } case sdl.K_F9: k.a.SendCommand(izapple2.CommandDumpDebugInfo) case sdl.K_F10: diff --git a/frontend/a2sdl3/main.go b/frontend/a2sdl3/main.go index ace08188c..d22906cb5 100644 --- a/frontend/a2sdl3/main.go +++ b/frontend/a2sdl3/main.go @@ -79,6 +79,8 @@ func sdlRun(a *izapple2.Apple2) { m := newSDL3Mouse() a.SetMouseProvider(m) + d := newSDL3DropTargets(a, window) + // go-sdl3 asks for SIGINT and SIGTERM to remove the folder it extracted // SDL3 to, but it does not end the process afterwards. Asking for them too // puts them back to work: a Ctrl-C or a kill now quits the same way as @@ -122,17 +124,29 @@ func sdlRun(a *izapple2.Apple2) { e := event.MouseMotionEvent() j.putMouseMotionEvent(e, w, h) m.putMouseMotionEvent(e, w, h) + d.dragEnded() case sdl.EVENT_MOUSE_BUTTON_DOWN, sdl.EVENT_MOUSE_BUTTON_UP: e := event.MouseButtonEvent() j.putMouseButtonEvent(e) m.putMouseButtonEvent(e) + case sdl.EVENT_DROP_BEGIN: + // Unlike SDL2, SDL3 reports the file being dragged over the + // window, so the drop targets can be shown while it moves. + d.dragStarted() + case sdl.EVENT_DROP_POSITION: + d.dragMoved(event.DropEvent().X) + case sdl.EVENT_DROP_COMPLETE: + d.dragEnded() case sdl.EVENT_DROP_FILE: - // Unlike SDL2, SDL3 reports where the file was dropped. e := event.DropEvent() - w, _, _ := window.Size() - drive := int(2 * int32(e.X) / w) - fmt.Printf("Loading '%s' in drive %v\n", e.Data, drive+1) - a.SendLoadDisk(drive, e.Data) + d.dragEnded() + drive := d.dropped(e.X) + if drive >= 0 { + fmt.Printf("Loading '%s' in drive %v\n", e.Data, drive+1) + a.SendLoadDisk(drive, e.Data) + } else { + fmt.Printf("There are no drives to load '%s' on\n", e.Data) + } } } @@ -149,7 +163,9 @@ func sdlRun(a *izapple2.Apple2) { var img *image.RGBA vs := a.GetVideoSource() if kp.showHelp { - img = screen.SnapshotMessageGenerator(vs, helpMessage) + img = screen.SnapshotMessageGenerator(vs, helpMessage, false /*is80Columns*/) + } else if d.showing(kp.showDropTargets) { + img = d.snapshot() } else if kp.showCharGen { cgPage, cgPages := a.GetCgPageInfo() img = screen.SnapshotCharacterGenerator(vs, kp.showAltText) @@ -198,6 +214,7 @@ var helpMessage = ` Ctrl-F5: Show speed F6: Next screen mode F7: Show/Hide pages + F8: Show/Hide drop targets F10: Next character set Ctrl-F10: Show/Hide character set Shift-F10: Show/Hide alternate text @@ -207,8 +224,7 @@ var helpMessage = ` Left alt or option key: Open-Apple Right alt or option key: Closed-Apple -Drop a file on the left or right -side of the window to load a disk +Drop a file on a drive area to load it Run izapple2 -h for more options https://github.com/ivanizag/izapple2 diff --git a/frontend/a2sdl3/sdl3DropTargets.go b/frontend/a2sdl3/sdl3DropTargets.go new file mode 100644 index 000000000..10c000119 --- /dev/null +++ b/frontend/a2sdl3/sdl3DropTargets.go @@ -0,0 +1,100 @@ +//go:build !js + +package main + +import ( + "image" + + "github.com/ivanizag/izapple2" + "github.com/ivanizag/izapple2/frontend/shared" + + "github.com/Zyko0/go-sdl3/sdl" +) + +/* +The window is divided in as many vertical areas as removable media drives, to +show where a file can be dropped and what each drive has inserted. + +Unlike SDL2, SDL3 reports the position of a file dragged over the window, so +the areas are shown while the drag lasts, with the one under the pointer +highlighted. They can also be shown with F8, and after a drop to tell where the +file went. +*/ + +type sdl3DropTargets struct { + targets *shared.DropTargets + window *sdl.Window + + dragging bool // A file is being dragged over the window + dragKnown bool // The position of the file has been reported + dragX float32 // Where the file is on the window +} + +func newSDL3DropTargets(a *izapple2.Apple2, window *sdl.Window) *sdl3DropTargets { + var d sdl3DropTargets + d.targets = shared.NewDropTargets(a) + d.window = window + return &d +} + +// dragStarted is called when a file begins to be dragged over the window, +// before knowing where it is +func (d *sdl3DropTargets) dragStarted() { + d.dragging = true + d.dragKnown = false +} + +// dragMoved tracks a file being dragged over the window +func (d *sdl3DropTargets) dragMoved(x float32) { + d.dragging = true + d.dragKnown = true + d.dragX = x +} + +// dragEnded is called when the file is dropped or leaves the window. The mouse +// motion events do not arrive while a file is being dragged, so the first one +// also means that the drag is over, whatever SDL reported. +func (d *sdl3DropTargets) dragEnded() { + d.dragging = false +} + +// dropped returns the drive that gets a file dropped at the given position of +// the window +func (d *sdl3DropTargets) dropped(x float32) int { + drive := d.driveAt(x) + if drive >= 0 { + d.targets.Dropped(drive) + } + return drive +} + +// showing returns whether the areas take over the screen, either because a +// file is being dragged, because the user asked for them with F8, or because a +// file has just been dropped +func (d *sdl3DropTargets) showing(requested bool) bool { + return d.dragging || requested || d.targets.Flashing() +} + +// snapshot returns the screen with the areas, highlighting the drive under the +// file being dragged or the one that got the last file +func (d *sdl3DropTargets) snapshot() *image.RGBA { + selected := -1 + if d.dragging { + if d.dragKnown { + selected = d.driveAt(d.dragX) + } + } else if d.targets.Flashing() { + selected = d.targets.FlashDrive() + } + + return d.targets.Snapshot(selected) +} + +func (d *sdl3DropTargets) driveAt(x float32) int { + width, _, err := d.window.Size() + if err != nil { + return -1 + } + + return d.targets.DriveAt(int(x), int(width)) +} diff --git a/frontend/a2sdl3/sdl3Keyboard.go b/frontend/a2sdl3/sdl3Keyboard.go index 4395e7591..169942c38 100644 --- a/frontend/a2sdl3/sdl3Keyboard.go +++ b/frontend/a2sdl3/sdl3Keyboard.go @@ -16,11 +16,12 @@ type sdl3Keyboard struct { a *izapple2.Apple2 keyChannel *izapple2.KeyboardChannel - showHelp bool - showPages bool - showCharGen bool - showAltText bool - screenMode int + showHelp bool + showPages bool + showCharGen bool + showAltText bool + showDropTargets bool + screenMode int } func newSDL3Keyboard(a *izapple2.Apple2) *sdl3Keyboard { @@ -118,6 +119,12 @@ func (k *sdl3Keyboard) putKey(keyEvent *sdl.KeyboardEvent) { k.screenMode = screen.NextScreenMode(k.screenMode) case sdl.K_F7: k.showPages = !k.showPages + case sdl.K_F8: + k.showDropTargets = !k.showDropTargets + if k.showDropTargets { + // The help is shown on top of the drop targets, get it out of the way + k.showHelp = false + } case sdl.K_F9: k.a.SendCommand(izapple2.CommandDumpDebugInfo) case sdl.K_F10: diff --git a/frontend/shared/dropTargets.go b/frontend/shared/dropTargets.go new file mode 100644 index 000000000..22ade1232 --- /dev/null +++ b/frontend/shared/dropTargets.go @@ -0,0 +1,210 @@ +// Package shared has the code the frontends have in common: the parts that are +// not the emulated machine, and would be copied from one frontend to the next +// otherwise. +package shared + +import ( + "image" + "strings" + "time" + + "github.com/ivanizag/izapple2" + "github.com/ivanizag/izapple2/screen" +) + +/* +A file dragged on the window can be dropped on any of the removable media +drives of the machine. The window is divided in as many vertical areas as +drives, and this builds the screen that shows them, each one with the name of +its drive and the media it has inserted. + +It is a text screen of 80 columns rendered with the character generator of the +machine, the same way the frontends show their help. +*/ + +const ( + dropTargetsColumns = 80 + dropTargetsLines = 24 + + dropTargetsTitle = "DROP A FILE ON A DRIVE" + dropTargetsFooter = "The file goes to the drive of the area it is dropped on" + dropTargetsEmpty = "EMPTY" + + dropTargetsTitleLine = 1 // Title, centered on the screen + dropTargetsTopLine = 3 // Top of the areas + dropTargetsRuleTop = 4 // Rule over the drive selected + dropTargetsLabelLine = 6 // Name of the drive + dropTargetsMediaLine = 8 // First line with the media inserted + dropTargetsMediaLines = 6 // Lines available for the media inserted + dropTargetsRuleBottom = 15 // Rule under the drive selected + dropTargetsBottomLine = 17 // Bottom of the areas, not included + dropTargetsFooterLine = 21 // Footer, centered on the screen + + // DropTargetsFlashDuration is how long the areas are shown after a drop, + // to tell where the file went + DropTargetsFlashDuration = 1500 * time.Millisecond +) + +// DropTargets shows where a file dragged on the window can be dropped +type DropTargets struct { + a *izapple2.Apple2 + + flashUntil time.Time // The areas are shown for a while after a drop + flashDrive int +} + +func NewDropTargets(a *izapple2.Apple2) *DropTargets { + var d DropTargets + d.a = a + d.flashDrive = -1 + return &d +} + +// Count returns how many drives a file can be dropped on +func (d *DropTargets) Count() int { + return len(d.a.GetRemovableMediaDrives()) +} + +// DriveAt returns the drive that would get a file dropped at the x position of +// a window of the given width. It returns -1 if there are no drives to drop a +// file on. +func (d *DropTargets) DriveAt(x int, width int) int { + return DropTargetIndex(x, width, d.Count()) +} + +// Dropped notes the drive a file has just gone to, to show the areas for a +// moment with that drive highlighted +func (d *DropTargets) Dropped(drive int) { + d.flashDrive = drive + d.flashUntil = time.Now().Add(DropTargetsFlashDuration) +} + +// Flashing returns whether the areas are being shown after a drop +func (d *DropTargets) Flashing() bool { + return time.Now().Before(d.flashUntil) +} + +// FlashDrive returns the drive the last file dropped went to +func (d *DropTargets) FlashDrive() int { + return d.flashDrive +} + +// Snapshot returns the screen with the areas of the drives, with the one +// passed highlighted. Pass -1 to highlight none of them. +func (d *DropTargets) Snapshot(selected int) *image.RGBA { + drives := d.a.GetRemovableMediaDrives() + return screen.SnapshotMessageGenerator(d.a.GetVideoSource(), + dropTargetsMessage(drives, selected), true /*is80Columns*/) +} + +// DropTargetIndex returns the drive that would get a file dropped at the x +// position of a window of the given width. It returns -1 if there are no +// drives to drop a file on. +func DropTargetIndex(x int, width int, driveCount int) int { + if driveCount <= 0 || width <= 0 { + return -1 + } + + index := x * driveCount / width + if index < 0 { + return 0 + } + if index >= driveCount { + return driveCount - 1 + } + return index +} + +// dropTargetsMessage builds the text screen with an area per drive +func dropTargetsMessage(drives []izapple2.DriveInfo, selected int) string { + canvas := newTextCanvas(dropTargetsColumns, dropTargetsLines) + if len(drives) == 0 { + canvas.putTextCentered(0, dropTargetsColumns, dropTargetsLabelLine, + "There are no drives to drop a file on") + return canvas.String() + } + + canvas.putTextCentered(0, dropTargetsColumns, dropTargetsTitleLine, dropTargetsTitle) + canvas.putTextCentered(0, dropTargetsColumns, dropTargetsFooterLine, dropTargetsFooter) + + for i, drive := range drives { + left := i * dropTargetsColumns / len(drives) + right := (i + 1) * dropTargetsColumns / len(drives) + + // Divide the screen, as the window is divided + inLeft := left + 1 + if i != 0 { + canvas.putVerticalLine(left, dropTargetsTopLine, dropTargetsBottomLine, '|') + // Leave the same margin on both sides of the division + inLeft = left + 2 + } + inRight := right - 1 + + canvas.putTextCentered(inLeft, inRight, dropTargetsLabelLine, drive.Label) + + media := mediaDisplayName(drive.Media) + if media == "" { + media = dropTargetsEmpty + } + lines := splitInLines(media, inRight-inLeft, dropTargetsMediaLines) + for j, line := range lines { + canvas.putTextCentered(inLeft, inRight, dropTargetsMediaLine+j, line) + } + + if i == selected { + canvas.putHorizontalLine(inLeft, inRight, dropTargetsRuleTop, '=') + canvas.putHorizontalLine(inLeft, inRight, dropTargetsRuleBottom, '=') + } + } + + return canvas.String() +} + +// mediaDisplayName keeps the last segment of the name of a media, the part +// that tells the images apart +func mediaDisplayName(media string) string { + if i := strings.LastIndexAny(media, "/\\"); i != -1 { + return media[i+1:] + } + return media +} + +// splitInLines cuts a text in lines of at most the given columns, breaking on +// the spaces where it can. What does not fit in the lines available is +// replaced by an ellipsis. +func splitInLines(text string, columns int, maxLines int) []string { + if columns < 1 || maxLines < 1 { + return nil + } + + lines := make([]string, 0, maxLines) + for len(text) > columns { + if len(lines) == maxLines-1 { + return append(lines, ellipsize(text, columns)) + } + + // The first space that would be left out is where the line is cut. + // Without one, the word is longer than the line and has to be broken. + cut := strings.LastIndex(text[:columns+1], " ") + if cut <= 0 { + lines = append(lines, text[:columns]) + text = text[columns:] + continue + } + + lines = append(lines, strings.TrimRight(text[:cut], " ")) + text = strings.TrimLeft(text[cut:], " ") + } + return append(lines, text) +} + +// ellipsize cuts a text to the columns given, marking that there was more +func ellipsize(text string, columns int) string { + if len(text) <= columns { + return text + } + if columns > 3 { + return text[:columns-3] + "..." + } + return text[:columns] +} diff --git a/frontend/shared/dropTargets_test.go b/frontend/shared/dropTargets_test.go new file mode 100644 index 000000000..158fc5626 --- /dev/null +++ b/frontend/shared/dropTargets_test.go @@ -0,0 +1,126 @@ +package shared + +import ( + "strings" + "testing" + + "github.com/ivanizag/izapple2" +) + +func TestDropTargetIndex(t *testing.T) { + cases := []struct { + x int + width int + drives int + expected int + }{ + {0, 400, 2, 0}, + {199, 400, 2, 0}, + {200, 400, 2, 1}, + {399, 400, 2, 1}, + {0, 400, 4, 0}, + {250, 400, 4, 2}, + {-10, 400, 4, 0}, // Out of the window on the left + {500, 400, 4, 3}, // Out of the window on the right + {100, 400, 0, -1}, // No drives to drop a file on + {100, 0, 2, -1}, // Window not sized yet + } + + for _, c := range cases { + actual := DropTargetIndex(c.x, c.width, c.drives) + if actual != c.expected { + t.Errorf("DropTargetIndex(%v, %v, %v) is %v, expected %v", + c.x, c.width, c.drives, actual, c.expected) + } + } +} + +func TestSplitInLines(t *testing.T) { + cases := []struct { + text string + columns int + maxLines int + expected []string + }{ + {"dos33.dsk", 9, 3, []string{"dos33.dsk"}}, + {"dos33.dsk", 5, 3, []string{"dos33", ".dsk"}}, // No spaces to break on + {"Total Replay v5.dsk", 11, 3, []string{"Total", "Replay", "v5.dsk"}}, // Broken on the spaces + {"Total Replay v5.dsk", 11, 2, []string{"Total", "Replay v..."}}, // What is left does not fit + {"", 8, 3, []string{""}}, + } + + for _, c := range cases { + actual := splitInLines(c.text, c.columns, c.maxLines) + if strings.Join(actual, "|") != strings.Join(c.expected, "|") { + t.Errorf("splitInLines(%q, %v, %v) is %q, expected %q", + c.text, c.columns, c.maxLines, actual, c.expected) + } + if len(actual) > c.maxLines { + t.Errorf("splitInLines(%q, %v, %v) returned %v lines", + c.text, c.columns, c.maxLines, len(actual)) + } + for _, line := range actual { + if len(line) > c.columns { + t.Errorf("splitInLines(%q, %v, %v) returned the long line %q", + c.text, c.columns, c.maxLines, line) + } + } + } +} + +func TestMediaDisplayName(t *testing.T) { + cases := map[string]string{ + "dos33.dsk": "dos33.dsk", + "/home/user/disks/dos33.dsk": "dos33.dsk", + "C:\\disks\\dos33.dsk": "dos33.dsk", + "/dos33.dsk": "dos33.dsk", + "": "", + } + + for media, expected := range cases { + actual := mediaDisplayName(media) + if actual != expected { + t.Errorf("mediaDisplayName(%q) is %q, expected %q", media, actual, expected) + } + } +} + +// TestDropTargetsMessageFits verifies that the screen built is not wider or +// taller than what SnapshotMessageGenerator shows, whatever the drives are +func TestDropTargetsMessageFits(t *testing.T) { + drives := []izapple2.DriveInfo{ + {Label: "S5D1", Media: "/home/user/disks/Total Replay v5.0.2mg"}, + {Label: "S5D2", Media: ""}, + {Label: "S6D1", Media: "dos33.dsk"}, + {Label: "S6D2", Media: strings.Repeat("long", 40) + ".dsk"}, + } + + for count := 1; count <= len(drives); count++ { + for selected := -1; selected < count; selected++ { + message := dropTargetsMessage(drives[:count], selected) + lines := strings.Split(message, "\n") + if len(lines) > dropTargetsLines { + t.Errorf("%v drives, selected %v: %v lines", count, selected, len(lines)) + } + for i, line := range lines { + if len(line) > dropTargetsColumns { + t.Errorf("%v drives, selected %v: line %v is %v columns", + count, selected, i, len(line)) + } + } + for _, drive := range drives[:count] { + if !strings.Contains(message, drive.Label) { + t.Errorf("%v drives, selected %v: %v is missing", + count, selected, drive.Label) + } + } + } + } +} + +func TestDropTargetsMessageWithoutDrives(t *testing.T) { + message := dropTargetsMessage(nil, -1) + if !strings.Contains(message, "no drives") { + t.Errorf("the screen without drives should say so, it is %q", message) + } +} diff --git a/frontend/shared/textCanvas.go b/frontend/shared/textCanvas.go new file mode 100644 index 000000000..f60fef27a --- /dev/null +++ b/frontend/shared/textCanvas.go @@ -0,0 +1,72 @@ +package shared + +import "strings" + +/* +A grid of characters to compose the screens the frontends show with +screen.SnapshotMessageGenerator, like the drop targets. Everything written +outside the grid is discarded, so the callers do not have to check the bounds +of what they place. +*/ + +type textCanvas struct { + columns int + lines int + chars []byte +} + +func newTextCanvas(columns int, lines int) *textCanvas { + var c textCanvas + c.columns = columns + c.lines = lines + c.chars = make([]byte, columns*lines) + for i := range c.chars { + c.chars[i] = ' ' + } + return &c +} + +func (c *textCanvas) setChar(column int, line int, char byte) { + if column < 0 || column >= c.columns || line < 0 || line >= c.lines { + return + } + c.chars[line*c.columns+column] = char +} + +// putText writes a text from a column to the right +func (c *textCanvas) putText(column int, line int, text string) { + for i := range len(text) { + c.setChar(column+i, line, text[i]) + } +} + +// putTextCentered writes a text centered between two columns, the right one +// not included +func (c *textCanvas) putTextCentered(left int, right int, line int, text string) { + c.putText(left+(right-left-len(text))/2, line, text) +} + +// putHorizontalLine fills a line with a character between two columns, the +// right one not included +func (c *textCanvas) putHorizontalLine(left int, right int, line int, char byte) { + for column := left; column < right; column++ { + c.setChar(column, line, char) + } +} + +// putVerticalLine fills a column with a character between two lines, the +// bottom one not included +func (c *textCanvas) putVerticalLine(column int, top int, bottom int, char byte) { + for line := top; line < bottom; line++ { + c.setChar(column, line, char) + } +} + +func (c *textCanvas) String() string { + lines := make([]string, c.lines) + for line := range c.lines { + lines[line] = strings.TrimRight( + string(c.chars[line*c.columns:(line+1)*c.columns]), " ") + } + return strings.Join(lines, "\n") +} diff --git a/removableMedia.go b/removableMedia.go new file mode 100644 index 000000000..fd011e9e4 --- /dev/null +++ b/removableMedia.go @@ -0,0 +1,62 @@ +package izapple2 + +import ( + "sync/atomic" +) + +/* +The removable media drives are the drives a file can be loaded on while the +emulation runs, by command or by dragging a file on the window. They are +registered by the cards that own them, in the order the frontends and the +CommandLoadDisk command use to address them. +*/ + +// mediaName is the name of the media loaded on a removable media drive. It is +// written by the emulation goroutine when a file is inserted, and read by the +// frontends to show what each drive has, hence the atomic access. +type mediaName struct { + value atomic.Value +} + +func (m *mediaName) set(name string) { + m.value.Store(name) +} + +func (m *mediaName) get() string { + name, _ := m.value.Load().(string) + return name +} + +// removableMediaDrive is a drive registered as a target for the files loaded +// while the emulation runs +type removableMediaDrive struct { + label string // Slot and drive number, for example "S6D1" + drive drive +} + +func (a *Apple2) registerRemovableMediaDrive(d drive, label string) { + a.removableMediaDrives = append(a.removableMediaDrives, removableMediaDrive{ + label: label, + drive: d, + }) +} + +// DriveInfo describes a removable media drive as seen from outside +type DriveInfo struct { + Label string // Slot and drive number, for example "S6D1" + Media string // Name of the media inserted, empty when the drive is empty +} + +// GetRemovableMediaDrives returns the drives a file can be loaded on, in the +// order SendLoadDisk() addresses them. The frontends use it to show where a +// dragged file can be dropped and what each drive has inserted. +func (a *Apple2) GetRemovableMediaDrives() []DriveInfo { + drives := make([]DriveInfo, len(a.removableMediaDrives)) + for i, d := range a.removableMediaDrives { + drives[i] = DriveInfo{ + Label: d.label, + Media: d.drive.getMediaName(), + } + } + return drives +} diff --git a/screen/snapshotsDebug.go b/screen/snapshotsDebug.go index 93f5534eb..9c8d38b0f 100644 --- a/screen/snapshotsDebug.go +++ b/screen/snapshotsDebug.go @@ -160,21 +160,30 @@ func SnapshotCharacterGenerator(vs VideoSource, isAltText bool) *image.RGBA { return snap } -// SnapshotMessageGenerator shows a message on the screen -func SnapshotMessageGenerator(vs VideoSource, message string) *image.RGBA { +// SnapshotMessageGenerator shows a message on the screen, on 40 or 80 columns. +// The character generator of the machine is used, but not its text memory, so +// it works whatever the machine is displaying and whether or not it has 80 +// columns of its own. +func SnapshotMessageGenerator(vs VideoSource, message string, is80Columns bool) *image.RGBA { if !vs.SupportsLowercase() { message = strings.ToUpper(message) } + + columns := text40Columns + if is80Columns { + columns = 2 * text40Columns + } + lines := strings.Split(message, "\n") - text := make([]uint8, textLines*text40Columns) + text := make([]uint8, textLines*columns) for i := range text { text[i] = 0x20 + 0x80 // Space } for l, line := range lines { for c, char := range line { - if c < text40Columns && l < textLines { - text[text40Columns*l+c] = uint8(char) + 0x80 + if c < columns && l < textLines { + text[columns*l+c] = uint8(char) + 0x80 } } }