diff --git a/AGENTS.md b/AGENTS.md index 91c3e3f7..30be0b28 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 3aab9cf6..654b752b 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 471e4082..991f54c2 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 3815b08b..85f3162f 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 3cb9fae0..459b0d06 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 ae69adcc..a755158e 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 129082b7..3aaa5dc9 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 43f29544..34fd00f2 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 ada47770..a2faf739 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 6fb351d8..b3f8c8cb 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 00000000..8021e604 --- /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 eb949b76..de57b015 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 ec26ce43..e628784f 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 fb4eec33..954b76f0 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 00000000..144bc781 --- /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 6861838b..633aa3f1 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 ace08188..d22906cb 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 00000000..10c00011 --- /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 4395e759..169942c3 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 00000000..22ade123 --- /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 00000000..158fc562 --- /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 00000000..f60fef27 --- /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 00000000..fd011e9e --- /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 93f5534e..9c8d38b0 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 } } }